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:
JianFeeeee
2026-09-08 22:50:08 +08:00
parent 33564fb8f5
commit c8aa6946c1

View File

@ -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
}