From c8aa6946c10e130d09411bc4253262454505afc4 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 8 Sep 2026 22:50:08 +0800 Subject: [PATCH] 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 --- history-server/main.go | 374 +++++++++++++++++++++++++++++------------ 1 file changed, 264 insertions(+), 110 deletions(-) diff --git a/history-server/main.go b/history-server/main.go index 0b7aa1b..4bc33bc 100644 --- a/history-server/main.go +++ b/history-server/main.go @@ -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 +}