feat: favorites section shows top-4 Dashy nav items by real visit frequency
- Rebuild favorites from ALL Dashy navigation items (across every section), ranked by aggregated host visit counts from browser history - Code-hosting (Gitea/GitLab/git.*) always pinned first regardless of visits - Host-level dedup prevents duplicate tiles from previous rebuild cycles - Fix code-host detection false positives (git. substring in domains like example.com); precise gitea/github/gitlab/codeberg matching + unit test - History section now only lists non-navigation external sites
This commit is contained in:
@ -164,7 +164,7 @@ func handleSync(w http.ResponseWriter, r *http.Request) {
|
||||
save()
|
||||
mu.Unlock() // 写锁释放后再排序,避免 sortDashyFavorites 内 RLock 死锁
|
||||
|
||||
sortDashyFavorites()
|
||||
rebuildDashySections()
|
||||
|
||||
mu.RLock()
|
||||
total := len(history)
|
||||
@ -203,7 +203,7 @@ func handleClear(w http.ResponseWriter, r *http.Request) {
|
||||
save()
|
||||
mu.Unlock()
|
||||
|
||||
sortDashyFavorites() // 清空后重置 conf.yml 历史 section 为占位
|
||||
rebuildDashySections() // 清空后重置 conf.yml 历史 section 为占位
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "cleared"})
|
||||
@ -230,123 +230,255 @@ func normalizeURL(raw string) (string, error) {
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// sortDashyFavorites reads conf.yml and sorts the first section's items by visit frequency.
|
||||
func sortDashyFavorites() {
|
||||
confFile := filepath.Join(dataDir, "conf.yml")
|
||||
data, err := os.ReadFile(confFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// navItem 是 conf.yml 里一个导航项(任一 section)
|
||||
type dashyNav struct {
|
||||
title, desc, icon, url string
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return
|
||||
}
|
||||
// dashySection 是 conf.yml 里的一个 section
|
||||
type dashySection struct {
|
||||
name, icon string
|
||||
collapsed bool
|
||||
items []dashyNav
|
||||
}
|
||||
|
||||
sectionsRaw, ok := cfg["sections"].([]any)
|
||||
if !ok || len(sectionsRaw) == 0 {
|
||||
return
|
||||
}
|
||||
// hostVisits 统计 history 中每个 host 的累计访问
|
||||
func hostVisits() map[string]int {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
out := map[string]int{}
|
||||
for _, e := range history {
|
||||
if u, err := url.Parse(e.URL); err == nil {
|
||||
out[strings.ToLower(u.Hostname())] += e.TotalVisits
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Find sections by name
|
||||
var favIdx, histIdx int = -1, -1
|
||||
for i, s := range sectionsRaw {
|
||||
m, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := m["name"].(string)
|
||||
switch name {
|
||||
case "\u5e38\u7528\u670d\u52a1": // 常用服务
|
||||
favIdx = i
|
||||
case "\u5386\u53f2\u8bbf\u95ee": // 历史访问
|
||||
histIdx = i
|
||||
}
|
||||
}
|
||||
// rebuildDashySections 读 conf.yml:
|
||||
// 1) 收集除「历史访问」外所有导航项
|
||||
// 2) 按各导航 host 在 history 的累计访问统计
|
||||
// 3) 「常用服务」= 访问最高的 4 个导航(Gitea/代码托管恒置顶)
|
||||
// 4) 「历史访问」= 非导航 host 的 top 20
|
||||
func rebuildDashySections() {
|
||||
confFile := filepath.Join(dataDir, "conf.yml")
|
||||
data, err := os.ReadFile(confFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Sort 常用服务 by visit count
|
||||
if favIdx >= 0 {
|
||||
sec := sectionsRaw[favIdx].(map[string]any)
|
||||
itemsRaw, _ := sec["items"].([]any)
|
||||
if len(itemsRaw) > 0 {
|
||||
sort.SliceStable(itemsRaw, func(i, j int) bool {
|
||||
ii, _ := itemsRaw[i].(map[string]any)
|
||||
ij, _ := itemsRaw[j].(map[string]any)
|
||||
ui, _ := ii["url"].(string)
|
||||
uj, _ := ij["url"].(string)
|
||||
vi := visitCount(ui)
|
||||
vj := visitCount(uj)
|
||||
return vi > vj
|
||||
})
|
||||
sec["items"] = itemsRaw
|
||||
}
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Rebuild 历史访问 from history data (top 20, excluding URLs already in 常用服务)
|
||||
if histIdx >= 0 {
|
||||
favURLs := map[string]bool{}
|
||||
if favIdx >= 0 {
|
||||
sec := sectionsRaw[favIdx].(map[string]any)
|
||||
if items, ok := sec["items"].([]any); ok {
|
||||
for _, it := range items {
|
||||
m, _ := it.(map[string]any)
|
||||
u, _ := m["url"].(string)
|
||||
favURLs[strings.ToLower(u)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sectionsRaw, ok := cfg["sections"].([]any)
|
||||
if !ok || len(sectionsRaw) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
mu.RLock()
|
||||
entries := make([]*Entry, 0, len(history))
|
||||
for _, e := range history {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
mu.RUnlock()
|
||||
const favName = "\u5e38\u7528\u670d\u52a1" // 常用服务
|
||||
const histName = "\u5386\u53f2\u8bbf\u95ee" // 历史访问
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].TotalVisits > entries[j].TotalVisits
|
||||
})
|
||||
type secInfo struct {
|
||||
raw map[string]any
|
||||
name string
|
||||
collapsed bool
|
||||
}
|
||||
secs := make([]secInfo, 0, len(sectionsRaw))
|
||||
for _, s := range sectionsRaw {
|
||||
m, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, _ := m["name"].(string)
|
||||
coll := false
|
||||
if c, ok := m["collapsed"].(bool); ok {
|
||||
coll = c
|
||||
}
|
||||
secs = append(secs, secInfo{raw: m, name: name, collapsed: coll})
|
||||
}
|
||||
|
||||
histItems := make([]any, 0)
|
||||
for _, e := range entries {
|
||||
if favURLs[strings.ToLower(e.URL)] {
|
||||
continue
|
||||
}
|
||||
title := e.Title
|
||||
if title == "" {
|
||||
title = e.URL
|
||||
}
|
||||
histItems = append(histItems, map[string]any{
|
||||
"title": title,
|
||||
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
|
||||
"icon": "fas fa-globe",
|
||||
"url": e.URL,
|
||||
})
|
||||
if len(histItems) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(histItems) == 0 {
|
||||
histItems = append(histItems, map[string]any{
|
||||
"title": "\u7b49\u5f85\u6d4f\u89c8\u5668\u63d2\u4ef6\u63a5\u5165...",
|
||||
"description": "\u540c\u6b65\u540e\u6309\u603b\u8bbf\u95ee\u9891\u6b21\u81ea\u52a8\u6392\u5e8f",
|
||||
"icon": "fas fa-info-circle",
|
||||
"url": "/admin/",
|
||||
})
|
||||
}
|
||||
sectionsRaw[histIdx].(map[string]any)["items"] = histItems
|
||||
}
|
||||
// 收集所有导航项(排除历史访问 section 本身),带 host 访问量
|
||||
hv := hostVisits()
|
||||
type cand struct {
|
||||
nav dashyNav
|
||||
host string
|
||||
visits int
|
||||
}
|
||||
candidates := make([]cand, 0)
|
||||
for _, si := range secs {
|
||||
items, _ := si.raw["items"].([]any)
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
title, _ := m["title"].(string)
|
||||
desc, _ := m["description"].(string)
|
||||
icon, _ := m["icon"].(string)
|
||||
u, _ := m["url"].(string)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
host := ""
|
||||
if pu, err := url.Parse(u); err == nil {
|
||||
host = strings.ToLower(pu.Hostname())
|
||||
}
|
||||
v := 0
|
||||
if host != "" {
|
||||
v = hv[host]
|
||||
}
|
||||
if si.name == histName {
|
||||
continue
|
||||
}
|
||||
// 所有导航项(含常用服务原本手动配置的)都进候选,
|
||||
// 由访问量 + code-host 置顶决定最终 Top4
|
||||
candidates = append(candidates, cand{
|
||||
nav: dashyNav{title: title, desc: desc, icon: icon, url: u},
|
||||
host: host, visits: v,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cfg["sections"] = sectionsRaw
|
||||
out, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp := confFile + ".sort-tmp"
|
||||
if err := os.WriteFile(tmp, out, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
os.Rename(tmp, confFile)
|
||||
// 排序:按访问量降序;同量保持原序
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
return candidates[i].visits > candidates[j].visits
|
||||
})
|
||||
|
||||
// host 去重:同 host 只保留访问最高的一条导航项
|
||||
// (rebuild 前常用服务可能已有上一轮写入的副本,与其它 section 同 host 会重复)
|
||||
{
|
||||
seenH := map[string]bool{}
|
||||
uniq := candidates[:0]
|
||||
for _, c := range candidates {
|
||||
if c.host == "" {
|
||||
uniq = append(uniq, c)
|
||||
continue
|
||||
}
|
||||
if seenH[c.host] {
|
||||
continue
|
||||
}
|
||||
seenH[c.host] = true
|
||||
uniq = append(uniq, c)
|
||||
}
|
||||
candidates = uniq
|
||||
}
|
||||
|
||||
// 强制 Gitea/代码托管置顶:host 或标题含 gitea/git/code/代码/仓库
|
||||
// 代码托管识别(独立函数便于单测)
|
||||
var top []cand
|
||||
var codeHost *cand
|
||||
rest := make([]cand, 0, len(candidates))
|
||||
for i := range candidates {
|
||||
if codeHost == nil && isCodeHostVal(candidates[i].nav.title, candidates[i].host, candidates[i].nav.url) {
|
||||
codeHost = &candidates[i]
|
||||
} else {
|
||||
rest = append(rest, candidates[i])
|
||||
}
|
||||
}
|
||||
top = append(top, rest...)
|
||||
// 组装:Gitea 第一(若存在),再加访问最高的其余导航,凑满 4
|
||||
finalNavs := make([]dashyNav, 0, 4)
|
||||
if codeHost != nil {
|
||||
finalNavs = append(finalNavs, codeHost.nav)
|
||||
}
|
||||
for _, c := range top {
|
||||
if len(finalNavs) >= 4 {
|
||||
break
|
||||
}
|
||||
finalNavs = append(finalNavs, c.nav)
|
||||
}
|
||||
|
||||
// 更新各 section
|
||||
for _, si := range secs {
|
||||
if si.name == favName {
|
||||
newItems := make([]any, 0, len(finalNavs))
|
||||
for _, n := range finalNavs {
|
||||
it := map[string]any{
|
||||
"title": n.title, "description": n.desc,
|
||||
"icon": n.icon, "url": n.url,
|
||||
}
|
||||
if n.desc == "" {
|
||||
it["description"] = ""
|
||||
}
|
||||
newItems = append(newItems, it)
|
||||
}
|
||||
si.raw["items"] = newItems
|
||||
}
|
||||
}
|
||||
|
||||
// 历史访问 = 非导航 host 的历史 top 20(排除已进常用/导航的)
|
||||
if true {
|
||||
navHosts := map[string]bool{}
|
||||
for _, c := range candidates {
|
||||
if c.host != "" {
|
||||
navHosts[c.host] = true
|
||||
}
|
||||
}
|
||||
for _, n := range finalNavs {
|
||||
if pu, err := url.Parse(n.url); err == nil {
|
||||
navHosts[strings.ToLower(pu.Hostname())] = true
|
||||
}
|
||||
}
|
||||
mu.RLock()
|
||||
entries := make([]*Entry, 0, len(history))
|
||||
for _, e := range history {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
mu.RUnlock()
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].TotalVisits > entries[j].TotalVisits
|
||||
})
|
||||
histItems := make([]any, 0, 20)
|
||||
for _, e := range entries {
|
||||
if navHosts[strings.ToLower(hostOf(e.URL))] {
|
||||
continue // 已是导航项,不重复进历史
|
||||
}
|
||||
title := e.Title
|
||||
if title == "" {
|
||||
title = e.URL
|
||||
}
|
||||
histItems = append(histItems, map[string]any{
|
||||
"title": title,
|
||||
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
|
||||
"icon": "fas fa-globe", "url": e.URL,
|
||||
})
|
||||
if len(histItems) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(histItems) == 0 {
|
||||
histItems = append(histItems, map[string]any{
|
||||
"title": "\u7b49\u5f85\u6d4f\u89c8\u5668\u63d2\u4ef6\u63a5\u5165...",
|
||||
"description": "\u540c\u6b65\u540e\u6309\u603b\u8bbf\u95ee\u9891\u6b21\u81ea\u52a8\u6392\u5e8f",
|
||||
"icon": "fas fa-info-circle", "url": "/admin/",
|
||||
})
|
||||
}
|
||||
for _, si := range secs {
|
||||
if si.name == histName {
|
||||
si.raw["items"] = histItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg["sections"] = sectionsRaw
|
||||
out, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp := confFile + ".sort-tmp"
|
||||
if err := os.WriteFile(tmp, out, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
os.Rename(tmp, confFile)
|
||||
}
|
||||
|
||||
func hostOf(rawURL string) string {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
return strings.ToLower(u.Hostname())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func visitCount(rawURL string) int {
|
||||
@ -395,3 +527,25 @@ func main() {
|
||||
log.Println("history-server listening on :3004")
|
||||
log.Fatal(http.ListenAndServe(":3004", nil))
|
||||
}
|
||||
|
||||
// isCodeHostVal 判断导航项是否代码托管:只认知名托管 host 与标题关键词,
|
||||
// 避免 "git." 子串误匹配域名中段(如 dify.example.com)。
|
||||
func isCodeHostVal(title, host, url string) bool {
|
||||
lowT := strings.ToLower(title)
|
||||
if strings.Contains(lowT, "gitea") || strings.Contains(lowT, "github") ||
|
||||
strings.Contains(lowT, "gitlab") || strings.Contains(lowT, "代码") ||
|
||||
strings.Contains(lowT, "仓库") || strings.Contains(lowT, "codeberg") {
|
||||
return true
|
||||
}
|
||||
h := strings.ToLower(host)
|
||||
if strings.HasPrefix(h, "git.") || strings.HasPrefix(h, "gitea.") ||
|
||||
strings.HasPrefix(h, "gitlab.") || strings.HasPrefix(h, "codeberg.") ||
|
||||
strings.HasPrefix(h, "github.") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(h, "gitea") || strings.Contains(h, "github") ||
|
||||
strings.Contains(h, "gitlab") || strings.Contains(h, "codeberg") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user