fix(core): BuildDir 只按模板里的分隔符分层

现象:默认模板 {title} 本该只出一层目录,但标题里带 | / : 的画廊
会凭空多出一层文件夹(6 个里 5 个中招)。

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

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

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

顺带一提:值里的非法字符以前是「变成目录」,现在才是本该有的
「清洗成 _」——两处回归测试都锁住了。
This commit is contained in:
2026-10-11 11:41:00 +08:00
parent a078a00f45
commit 77a422b9bf
2 changed files with 90 additions and 8 deletions
+21 -8
View File
@@ -48,6 +48,10 @@ var (
reIllegal = regexp.MustCompile(`[\\/:*?"<>|]`) reIllegal = regexp.MustCompile(`[\\/:*?"<>|]`)
reControl = regexp.MustCompile(`[\x00-\x1f\x7f]`) reControl = regexp.MustCompile(`[\x00-\x1f\x7f]`)
reSpace = regexp.MustCompile(`\s+`) reSpace = regexp.MustCompile(`\s+`)
// 目录分隔符 —— 只用来切「模板文本」本身。
// ⚠️ 千万别拿它去切占位符替换后的结果:那会把「值里的 / | : 」
// 变成真的目录层级。EH 标题惯例是 "原文 | 译名",一个竖线就多一层文件夹。
reSep = regexp.MustCompile(`[\\/]+`)
// 模板占位符:{key} 或 {tags:N} // 模板占位符:{key} 或 {tags:N}
rePlaceholder = regexp.MustCompile(`\{([a-zA-Z0-9_]+(?::\d+)?)\}`) rePlaceholder = regexp.MustCompile(`\{([a-zA-Z0-9_]+(?::\d+)?)\}`)
reTagsKey = regexp.MustCompile(`(?i)^tags(?::(\d+))?$`) reTagsKey = regexp.MustCompile(`(?i)^tags(?::(\d+))?$`)
@@ -132,6 +136,13 @@ func FallbackIfEmpty(s, fb string) string {
} }
// BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗) // BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗)
//
// ⚠️ 分层只认「模板文本里写的分隔符」。
// 占位符替换出来的值一律当普通名字:里面的 \ / : * ? " < > |
// 全部清洗成 _(SanitizeSegment 干的活),绝不凭空多出一层目录。
//
// 反例(2026-10 修):先替换再 reIllegal.Split,导致标题里的
// "[Warashibe] Enkou | 牝穴…" 被竖线切成两层文件夹。
func BuildDir(tpl string, g *eh.Gallery) string { func BuildDir(tpl string, g *eh.Gallery) string {
tpl = strings.TrimSpace(tpl) tpl = strings.TrimSpace(tpl)
if tpl == "" { if tpl == "" {
@@ -140,7 +151,7 @@ func BuildDir(tpl string, g *eh.Gallery) string {
vars := buildDirVars(tpl, g) vars := buildDirVars(tpl, g)
out := rePlaceholder.ReplaceAllStringFunc(tpl, func(m string) string { expand := func(m string) string {
key := strings.ToLower(m[1 : len(m)-1]) key := strings.ToLower(m[1 : len(m)-1])
// {tags} / {tags:N} —— 冒号后数字表示取前几个 // {tags} / {tags:N} —— 冒号后数字表示取前几个
if mt := reTagsKey.FindStringSubmatch(key); mt != nil { if mt := reTagsKey.FindStringSubmatch(key); mt != nil {
@@ -154,20 +165,22 @@ func BuildDir(tpl string, g *eh.Gallery) string {
return v return v
} }
return m // 未知占位符原样保留,避免用户以为拼错了却静默消失 return m // 未知占位符原样保留,避免用户以为拼错了却静默消失
}) }
// 逐段清洗(兼容 / 和 \ 两种分隔符输入) // 先按模板里的分隔符切段,再逐段展开占位符
segs := []string{} segs := []string{}
for _, s := range reIllegal.Split(out, -1) { for _, raw := range reSep.Split(tpl, -1) {
if strings.TrimSpace(s) == "" { if strings.TrimSpace(raw) == "" {
continue continue
} }
idx := len(segs)
fb := "未命名" fb := "未命名"
if idx == 0 { if len(segs) == 0 {
fb = g.GID fb = g.GID
} }
segs = append(segs, SanitizeSegment(s, fb)) s := SanitizeSegment(rePlaceholder.ReplaceAllStringFunc(raw, expand), fb)
if s != "" {
segs = append(segs, s)
}
} }
if len(segs) == 0 { if len(segs) == 0 {
+69
View File
@@ -66,6 +66,75 @@ func TestBuildDirDefaults(t *testing.T) {
} }
} }
// 占位符「值」里的非法字符只能清洗成 _,绝不能变成目录层级。
//
// 回归 2026-10 的线上问题:EH 标题惯例是「原文 | 译名」,
// 旧实现先替换占位符再按 [\\/:*?"<>|] 切分,于是标题里一个竖线
// 就凭空多出一层文件夹(6 个里 2 个中招)。
func TestBuildDirValueIllegalCharsDoNotNest(t *testing.T) {
sep := string(filepath.Separator)
tests := []struct {
name string
tpl string
title string
want string // 用 / 书写,断言前会换成本机分隔符
}{
{
name: "竖线(EH 标题惯例:原文 | 译名)",
tpl: "{title}",
title: "[Warashibe] Mesuana Hojiri Enkou | 牝穴開發援助交尾 [Chinese]",
want: "[Warashibe] Mesuana Hojiri Enkou _ 牝穴開發援助交尾 [Chinese]",
},
{
name: "斜杠",
tpl: "{title}",
title: "[Circle] AAA/BBB Collection",
want: "[Circle] AAA_BBB Collection",
},
{
name: "反斜杠",
tpl: "{title}",
title: `[Circle] AAA\BBB Collection`,
want: "[Circle] AAA_BBB Collection",
},
{
name: "冒号",
tpl: "{title}",
title: "Dai Ni Miyasaka Byouin 3 karte: Tsuyo Tanba",
want: "Dai Ni Miyasaka Byouin 3 karte_ Tsuyo Tanba",
},
{
name: "问号星号双引号尖括号",
tpl: "{title}",
title: `a?b*c"d<e>f|g`,
want: `a_b_c_d_e_f_g`,
},
{
name: "模板里的分隔符仍然生效(多级目录要保留)",
tpl: "{artist}/{title}",
title: "Enkou | 牝穴",
want: "Noxe/Enkou _ 牝穴",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := &eh.Gallery{GID: "3492157", Title: tt.title}
g.Meta.Artist = "Noxe"
got := BuildDir(tt.tpl, g)
want := strings.ReplaceAll(tt.want, "/", sep)
if got != want {
t.Errorf("\n got: %q\nwant: %q", got, want)
}
// 值里的非法字符不该产生额外层级:段数必须和模板段数一致
if n := strings.Count(got, sep); n != strings.Count(tt.tpl, "/") {
t.Errorf("层级数不对: got %q (%d 层)", got, n)
}
})
}
}
// 占位符兜底值 // 占位符兜底值
func TestBuildDirFallbacks(t *testing.T) { func TestBuildDirFallbacks(t *testing.T) {
g := &eh.Gallery{GID: "12345678", Title: "Plain Title"} g := &eh.Gallery{GID: "12345678", Title: "Plain Title"}