Files
eh-downloader/internal/core/paths.go
T
toor 77a422b9bf fix(core): BuildDir 只按模板里的分隔符分层
现象:默认模板 {title} 本该只出一层目录,但标题里带 | / : 的画廊
会凭空多出一层文件夹(6 个里 5 个中招)。

根因:把「非法文件名字符集」当成了「目录分隔符集」。两者只有 \ 和 /
重叠,剩下 : * ? " < > | 这 6 个字符被完全误当成目录分隔符。
更糟的是展开顺序——旧代码先把占位符全部替换完再切分,切分对象是
替换后的完整字符串,模板里的分隔符和值里的字符混在一起根本分不开。

EH 标题惯例是「原文 | 译名」,竖线极常见,所以中招率很高。

改动:改为先按模板文本切段(新增 reSep),再逐段展开占位符;
值里的非法字符一律交给 SanitizeSegment 清洗成 _,不再产生层级。
模板里自己写的 / 仍然正常分层,{artist}/{title} 行为不变。

顺带一提:值里的非法字符以前是「变成目录」,现在才是本该有的
「清洗成 _」——两处回归测试都锁住了。
2026-10-11 11:41:00 +08:00

267 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}