mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-11 14:20:32 +01:00
Split the monolithic `internal/homepage` icons functionality into a structured package hierarchy: - `internal/homepage/icons/` - Core types (URL, Key, Meta, Provider, Source, Variant) - `internal/homepage/icons/fetch/` - Icon fetching logic (content.go, fetch.go, route.go) - `internal/homepage/icons/list/` - Icon listing and search (list_icons.go, list_icons_test.go) Moved icon-related code from `internal/homepage/`: - `icon_url.go` → `icons/url.go` (+ url_test.go) - `content.go` → `icons/fetch/content.go` - `route.go` → `icons/fetch/route.go` - `list_icons.go` → `icons/list/list_icons.go` (+ list_icons_test.go) Updated all consumers to use the new package structure: - `cmd/main.go` - `internal/api/v1/favicon.go` - `internal/api/v1/icons.go` - `internal/idlewatcher/handle_http.go` - `internal/route/route.go`
368 lines
7.8 KiB
Go
368 lines
7.8 KiB
Go
package iconlist
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bytedance/sonic"
|
|
"github.com/lithammer/fuzzysearch/fuzzy"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/yusing/godoxy/internal/common"
|
|
"github.com/yusing/godoxy/internal/homepage/icons"
|
|
"github.com/yusing/godoxy/internal/serialization"
|
|
httputils "github.com/yusing/goutils/http"
|
|
"github.com/yusing/goutils/intern"
|
|
strutils "github.com/yusing/goutils/strings"
|
|
"github.com/yusing/goutils/synk"
|
|
"github.com/yusing/goutils/task"
|
|
)
|
|
|
|
type (
|
|
IconMap map[icons.Key]*icons.Meta
|
|
IconList []string
|
|
|
|
IconMetaSearch struct {
|
|
*icons.Meta
|
|
|
|
Source icons.Source `json:"Source"`
|
|
Ref string `json:"Ref"`
|
|
|
|
rank int
|
|
}
|
|
)
|
|
|
|
const updateInterval = 2 * time.Hour
|
|
|
|
var iconsCache synk.Value[IconMap]
|
|
|
|
const (
|
|
walkxcodeIcons = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/tree.json"
|
|
selfhstIcons = "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/index.json"
|
|
)
|
|
|
|
func InitCache() {
|
|
m := make(IconMap)
|
|
err := serialization.LoadJSONIfExist(common.IconListCachePath, &m)
|
|
if err != nil {
|
|
// backward compatible
|
|
oldFormat := struct {
|
|
Icons IconMap
|
|
LastUpdate time.Time
|
|
}{}
|
|
err = serialization.LoadJSONIfExist(common.IconListCachePath, &oldFormat)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("failed to load icons")
|
|
} else {
|
|
m = oldFormat.Icons
|
|
// store it to disk immediately
|
|
_ = serialization.SaveJSON(common.IconListCachePath, &m, 0o644)
|
|
}
|
|
} else if len(m) > 0 {
|
|
log.Info().
|
|
Int("icons", len(m)).
|
|
Msg("icons loaded")
|
|
} else {
|
|
if err := updateIcons(m); err != nil {
|
|
log.Error().Err(err).Msg("failed to update icons")
|
|
}
|
|
}
|
|
|
|
iconsCache.Store(m)
|
|
|
|
task.OnProgramExit("save_icons_cache", func() {
|
|
icons := iconsCache.Load()
|
|
_ = serialization.SaveJSON(common.IconListCachePath, &icons, 0o644)
|
|
})
|
|
|
|
go backgroundUpdateIcons()
|
|
}
|
|
|
|
func backgroundUpdateIcons() {
|
|
ticker := time.NewTicker(updateInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
log.Info().Msg("updating icon data")
|
|
newCache := make(IconMap, len(iconsCache.Load()))
|
|
if err := updateIcons(newCache); err != nil {
|
|
log.Error().Err(err).Msg("failed to update icons")
|
|
} else {
|
|
// swap old cache with new cache
|
|
iconsCache.Store(newCache)
|
|
// save it to disk
|
|
err := serialization.SaveJSON(common.IconListCachePath, &newCache, 0o644)
|
|
if err != nil {
|
|
log.Warn().Err(err).Msg("failed to save icons")
|
|
}
|
|
log.Info().Int("icons", len(newCache)).Msg("icons list updated")
|
|
}
|
|
case <-task.RootContext().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClearIconsCache() {
|
|
clear(iconsCache.Load())
|
|
}
|
|
|
|
func ListAvailableIcons() IconMap {
|
|
return iconsCache.Load()
|
|
}
|
|
|
|
func SearchIcons(keyword string, limit int) []*IconMetaSearch {
|
|
if keyword == "" {
|
|
return []*IconMetaSearch{}
|
|
}
|
|
|
|
if limit == 0 {
|
|
limit = 10
|
|
}
|
|
|
|
searchLimit := min(limit*5, 50)
|
|
|
|
results := make([]*IconMetaSearch, 0, searchLimit)
|
|
|
|
sortByRank := func(a, b *IconMetaSearch) int {
|
|
return a.rank - b.rank
|
|
}
|
|
|
|
var rank int
|
|
icons := ListAvailableIcons()
|
|
for k, icon := range icons {
|
|
if strutils.ContainsFold(string(k), keyword) || strutils.ContainsFold(icon.DisplayName, keyword) {
|
|
rank = 0
|
|
} else {
|
|
rank = fuzzy.RankMatchFold(keyword, string(k))
|
|
if rank == -1 || rank > 3 {
|
|
continue
|
|
}
|
|
}
|
|
|
|
source, ref := k.SourceRef()
|
|
ranked := &IconMetaSearch{
|
|
Source: source,
|
|
Ref: ref,
|
|
Meta: icon,
|
|
rank: rank,
|
|
}
|
|
// Sorted insert based on rank (lower rank = better match)
|
|
insertPos, _ := slices.BinarySearchFunc(results, ranked, sortByRank)
|
|
results = slices.Insert(results, insertPos, ranked)
|
|
if len(results) == searchLimit {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Extract results and limit to the requested count
|
|
return results[:min(len(results), limit)]
|
|
}
|
|
|
|
func HasIcon(icon *icons.URL) bool {
|
|
if icon.Extra == nil {
|
|
return false
|
|
}
|
|
if common.IsTest {
|
|
return true
|
|
}
|
|
meta, ok := ListAvailableIcons()[icon.Extra.Key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
switch icon.Extra.FileType {
|
|
case "png":
|
|
return meta.PNG && (!icon.Extra.IsLight || meta.Light) && (!icon.Extra.IsDark || meta.Dark)
|
|
case "svg":
|
|
return meta.SVG && (!icon.Extra.IsLight || meta.Light) && (!icon.Extra.IsDark || meta.Dark)
|
|
case "webp":
|
|
return meta.WebP && (!icon.Extra.IsLight || meta.Light) && (!icon.Extra.IsDark || meta.Dark)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type HomepageMeta struct {
|
|
DisplayName string
|
|
Tag string
|
|
}
|
|
|
|
func GetMetadata(ref string) (HomepageMeta, bool) {
|
|
meta, ok := ListAvailableIcons()[icons.NewKey(icons.SourceSelfhSt, ref)]
|
|
// these info is not available in walkxcode
|
|
// if !ok {
|
|
// meta, ok = iconsCache.Icons[icons.NewIconKey(icons.IconSourceWalkXCode, ref)]
|
|
// }
|
|
if !ok {
|
|
return HomepageMeta{}, false
|
|
}
|
|
return HomepageMeta{
|
|
DisplayName: meta.DisplayName,
|
|
Tag: meta.Tag,
|
|
}, true
|
|
}
|
|
|
|
func updateIcons(m IconMap) error {
|
|
if err := UpdateWalkxCodeIcons(m); err != nil {
|
|
return err
|
|
}
|
|
return UpdateSelfhstIcons(m)
|
|
}
|
|
|
|
var httpGet = httpGetImpl
|
|
|
|
func MockHTTPGet(body []byte) {
|
|
httpGet = func(_ string) ([]byte, func([]byte), error) {
|
|
return body, func([]byte) {}, nil
|
|
}
|
|
}
|
|
|
|
func httpGetImpl(url string) ([]byte, func([]byte), error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
return httputils.ReadAllBody(resp)
|
|
}
|
|
|
|
/*
|
|
format:
|
|
|
|
{
|
|
"png": [
|
|
"*.png",
|
|
],
|
|
"svg": [
|
|
"*.svg",
|
|
],
|
|
"webp": [
|
|
"*.webp",
|
|
]
|
|
}
|
|
*/
|
|
func UpdateWalkxCodeIcons(m IconMap) error {
|
|
body, release, err := httpGet(walkxcodeIcons)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
data := make(map[string][]string)
|
|
err = sonic.Unmarshal(body, &data)
|
|
release(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for fileType, files := range data {
|
|
var setExt func(icon *icons.Meta)
|
|
switch fileType {
|
|
case "png":
|
|
setExt = func(icon *icons.Meta) { icon.PNG = true }
|
|
case "svg":
|
|
setExt = func(icon *icons.Meta) { icon.SVG = true }
|
|
case "webp":
|
|
setExt = func(icon *icons.Meta) { icon.WebP = true }
|
|
}
|
|
for _, f := range files {
|
|
f = strings.TrimSuffix(f, "."+fileType)
|
|
isLight := strings.HasSuffix(f, "-light")
|
|
if isLight {
|
|
f = strings.TrimSuffix(f, "-light")
|
|
}
|
|
isDark := strings.HasSuffix(f, "-dark")
|
|
if isDark {
|
|
f = strings.TrimSuffix(f, "-dark")
|
|
}
|
|
key := icons.NewKey(icons.SourceWalkXCode, f)
|
|
icon, ok := m[key]
|
|
if !ok {
|
|
icon = new(icons.Meta)
|
|
m[key] = icon
|
|
}
|
|
setExt(icon)
|
|
if isLight {
|
|
icon.Light = true
|
|
}
|
|
if isDark {
|
|
icon.Dark = true
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/*
|
|
format:
|
|
|
|
{
|
|
"Name": "2FAuth",
|
|
"Reference": "2fauth",
|
|
"SVG": "Yes",
|
|
"PNG": "Yes",
|
|
"WebP": "Yes",
|
|
"Light": "Yes",
|
|
"Dark": "Yes",
|
|
"Tag": "",
|
|
"Category": "Self-Hosted",
|
|
"CreatedAt": "2024-08-16 00:27:23+00:00"
|
|
}
|
|
*/
|
|
|
|
func UpdateSelfhstIcons(m IconMap) error {
|
|
type SelfhStIcon struct {
|
|
Name string
|
|
Reference string
|
|
SVG string
|
|
PNG string
|
|
WebP string
|
|
Light string
|
|
Dark string
|
|
Tags string
|
|
}
|
|
|
|
body, release, err := httpGet(selfhstIcons)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
data := make([]SelfhStIcon, 0)
|
|
err = sonic.Unmarshal(body, &data) //nolint:musttag
|
|
release(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, item := range data {
|
|
var tag string
|
|
if item.Tags != "" {
|
|
tag, _, _ = strings.Cut(item.Tags, ",")
|
|
tag = strings.TrimSpace(tag)
|
|
}
|
|
icon := &icons.Meta{
|
|
DisplayName: item.Name,
|
|
Tag: intern.Make(tag).Value(),
|
|
SVG: item.SVG == "Yes",
|
|
PNG: item.PNG == "Yes",
|
|
WebP: item.WebP == "Yes",
|
|
Light: item.Light == "Yes",
|
|
Dark: item.Dark == "Yes",
|
|
}
|
|
key := icons.NewKey(icons.SourceSelfhSt, item.Reference)
|
|
m[key] = icon
|
|
}
|
|
return nil
|
|
}
|