package core // 下载目录模板引擎 // // 可用占位符: // // {title} 画廊标题 // {gid} 画廊号 // {artist} 作者(artist: 命名空间 → 标题 [X] → 上传者) // {group} 社团(group: 命名空间 → 标题 (X)) // {series} 系列/原作(parody: 命名空间) // {category} 分类(doujinshi / manga / misc …) // {lang} 语言 // {uploader} 上传者 // {tag} 第 1 个普通标签,同 {tag1} // {tag2} 第 2 个普通标签 … // {tags} 全部普通标签,用 _ 连接 // {tags:3} 前 3 个普通标签,用 _ 连接 // // 例:{artist}/{title} {category}/{group}/{title} {tags:2}/{artist}/{title} import ( "fmt" "net" "os" "path/filepath" "regexp" "strconv" "strings" "time" "unicode" "eh-downloader-wails/internal/eh" ) // Fallback 是各占位符的兜底值 var Fallback = map[string]string{ "artist": "未知作者", "group": "未知社团", "series": "无系列", "category": "未分类", "lang": "nolang", "uploader": "unknown", } var ( // 路径保留字符 + 控制字符 reIllegal = regexp.MustCompile(`[\\/:*?"<>|]`) reControl = regexp.MustCompile(`[\x00-\x1f\x7f]`) reSpace = regexp.MustCompile(`\s+`) // 目录分隔符 —— 只用来切「模板文本」本身。 // ⚠️ 千万别拿它去切占位符替换后的结果:那会把「值里的 / | : 」 // 变成真的目录层级。EH 标题惯例是 "原文 | 译名",一个竖线就多一层文件夹。 reSep = regexp.MustCompile(`[\\/]+`) // 模板占位符:{key} 或 {tags:N} rePlaceholder = regexp.MustCompile(`\{([a-zA-Z0-9_]+(?::\d+)?)\}`) reTagsKey = regexp.MustCompile(`(?i)^tags(?::(\d+))?$`) ) // SanitizeSegment 清洗单个路径段:干掉路径分隔符和保留字符 func SanitizeSegment(name, fallback string) string { s := reIllegal.ReplaceAllString(name, "_") s = reControl.ReplaceAllString(s, "_") s = reSpace.ReplaceAllString(s, " ") s = strings.TrimSpace(s) // "." / ".." 是危险路径 if s == "." || s == ".." { s = "_" } // Windows 不允许段名以点或空格结尾 s = strings.TrimRight(s, ". ") if s == "" { s = fallback } // 超长截断(Windows 路径上限 260,留足余量) if len([]rune(s)) > 110 { s = string([]rune(s)[:110]) s = strings.TrimRight(s, ". ") } // Windows 保留设备名(CON/PRN/AUX/NUL/COM1…)不能做目录名 if isWindowsReservedName(s) { s = "_" + s } return s } // joinTags 取前 n 个普通标签(下划线连接)。n<=0 表示全部。 func joinTags(tags []string, n int) string { if len(tags) == 0 { return "" } if n <= 0 || n > len(tags) { n = len(tags) } return strings.Join(tags[:n], "_") } // buildDirVars 展开模板得到全部变量 func buildDirVars(tpl string, g *eh.Gallery) map[string]string { meta := g.Meta vars := map[string]string{ "gid": g.GID, "title": g.Title, "artist": FallbackIfEmpty(meta.Artist, Fallback["artist"]), "group": FallbackIfEmpty(meta.Group, Fallback["group"]), "series": FallbackIfEmpty(meta.Series, Fallback["series"]), "category": FallbackIfEmpty(meta.Category, Fallback["category"]), "lang": FallbackIfEmpty(meta.Lang, Fallback["lang"]), "uploader": FallbackIfEmpty(meta.Uploader, Fallback["uploader"]), } if len(meta.Tags) > 0 { vars["tag"] = meta.Tags[0] vars["tags"] = joinTags(meta.Tags, 0) } else { vars["tag"] = Fallback["series"] vars["tags"] = "" } for i := 1; i <= 12; i++ { if i-1 < len(meta.Tags) { vars["tag"+strconv.Itoa(i)] = meta.Tags[i-1] } else { vars["tag"+strconv.Itoa(i)] = "" } } return vars } // FallbackIfEmpty 空字符串才用兜底值 func FallbackIfEmpty(s, fb string) string { if strings.TrimSpace(s) == "" { return fb } return s } // BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗) // // ⚠️ 分层只认「模板文本里写的分隔符」。 // 占位符替换出来的值一律当普通名字:里面的 \ / : * ? " < > | // 全部清洗成 _(SanitizeSegment 干的活),绝不凭空多出一层目录。 // // 反例(2026-10 修):先替换再 reIllegal.Split,导致标题里的 // "[Warashibe] Enkou | 牝穴…" 被竖线切成两层文件夹。 func BuildDir(tpl string, g *eh.Gallery) string { tpl = strings.TrimSpace(tpl) if tpl == "" { tpl = "{title}" } vars := buildDirVars(tpl, g) expand := func(m string) string { key := strings.ToLower(m[1 : len(m)-1]) // {tags} / {tags:N} —— 冒号后数字表示取前几个 if mt := reTagsKey.FindStringSubmatch(key); mt != nil { n := 0 if mt[1] != "" { n, _ = strconv.Atoi(mt[1]) } return joinTags(g.Meta.Tags, n) } if v, ok := vars[key]; ok { return v } return m // 未知占位符原样保留,避免用户以为拼错了却静默消失 } // 先按模板里的分隔符切段,再逐段展开占位符 segs := []string{} for _, raw := range reSep.Split(tpl, -1) { if strings.TrimSpace(raw) == "" { continue } fb := "未命名" if len(segs) == 0 { fb = g.GID } s := SanitizeSegment(rePlaceholder.ReplaceAllStringFunc(raw, expand), fb) if s != "" { segs = append(segs, s) } } if len(segs) == 0 { return SanitizeSegment(g.Title, g.GID) } return strings.Join(segs, string(filepath.Separator)) } // ListPlaceholders 给设置界面提示用 func ListPlaceholders() []string { return []string{ "{title}", "{gid}", "{artist}", "{group}", "{series}", "{category}", "{lang}", "{uploader}", "{tag}", "{tags}", "{tags:3}", } } // SanitizeFilename 清洗图片文件名(不含目录分隔) func SanitizeFilename(name, fallback string) string { s := reIllegal.ReplaceAllString(name, "_") s = reControl.ReplaceAllString(s, "_") s = strings.TrimSpace(s) if s == "" || s == "." || s == ".." { s = fallback } if len([]rune(s)) > 150 { s = string([]rune(s)[:150]) } return s } // DefaultDownloadRoot 默认下载目录 ~/Downloads/EH(Win/mac 通用) func DefaultDownloadRoot() string { home, err := os.UserHomeDir() if err != nil { return "EH" } return filepath.Join(home, "Downloads", "EH") } // DetectLocalProxy 探测本机常见代理端口(mihomo/clash/v2ray 等)。 // 真正等连接结果,连不上就试下一个端口 —— 别像 JS 版那样永远返回第一个。 func DetectLocalProxy(ports []int) string { for _, p := range ports { addr := fmt.Sprintf("127.0.0.1:%d", p) conn, err := net.DialTimeout("tcp", addr, 250*time.Millisecond) if err == nil { conn.Close() return "http://127.0.0.1:" + strconv.Itoa(p) } } return "" } // isWindowsReservedName Windows 保留设备名,目录名撞上会失败 func isWindowsReservedName(s string) bool { base := strings.ToUpper(strings.SplitN(s, ".", 2)[0]) switch base { case "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9": return true } return false } // validSegmentName 段名合法性检查(给测试用) func validSegmentName(s string) bool { if s == "" || s == "." || s == ".." { return false } if strings.ContainsAny(s, `\/:*?"<>|`) { return false } for _, r := range s { if unicode.IsControl(r) { return false } } if isWindowsReservedName(s) { return false } return true }