fix(core): BuildDir 只按模板里的分隔符分层
现象:默认模板 {title} 本该只出一层目录,但标题里带 | / : 的画廊
会凭空多出一层文件夹(6 个里 5 个中招)。
根因:把「非法文件名字符集」当成了「目录分隔符集」。两者只有 \ 和 /
重叠,剩下 : * ? " < > | 这 6 个字符被完全误当成目录分隔符。
更糟的是展开顺序——旧代码先把占位符全部替换完再切分,切分对象是
替换后的完整字符串,模板里的分隔符和值里的字符混在一起根本分不开。
EH 标题惯例是「原文 | 译名」,竖线极常见,所以中招率很高。
改动:改为先按模板文本切段(新增 reSep),再逐段展开占位符;
值里的非法字符一律交给 SanitizeSegment 清洗成 _,不再产生层级。
模板里自己写的 / 仍然正常分层,{artist}/{title} 行为不变。
顺带一提:值里的非法字符以前是「变成目录」,现在才是本该有的
「清洗成 _」——两处回归测试都锁住了。
This commit is contained in:
+21
-8
@@ -48,6 +48,10 @@ 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+))?$`)
|
||||
@@ -132,6 +136,13 @@ func FallbackIfEmpty(s, fb string) string {
|
||||
}
|
||||
|
||||
// BuildDir 用模板算出画廊的相对目录(相对 downloadRoot,逐段清洗)
|
||||
//
|
||||
// ⚠️ 分层只认「模板文本里写的分隔符」。
|
||||
// 占位符替换出来的值一律当普通名字:里面的 \ / : * ? " < > |
|
||||
// 全部清洗成 _(SanitizeSegment 干的活),绝不凭空多出一层目录。
|
||||
//
|
||||
// 反例(2026-10 修):先替换再 reIllegal.Split,导致标题里的
|
||||
// "[Warashibe] Enkou | 牝穴…" 被竖线切成两层文件夹。
|
||||
func BuildDir(tpl string, g *eh.Gallery) string {
|
||||
tpl = strings.TrimSpace(tpl)
|
||||
if tpl == "" {
|
||||
@@ -140,7 +151,7 @@ func BuildDir(tpl string, g *eh.Gallery) string {
|
||||
|
||||
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])
|
||||
// {tags} / {tags:N} —— 冒号后数字表示取前几个
|
||||
if mt := reTagsKey.FindStringSubmatch(key); mt != nil {
|
||||
@@ -154,20 +165,22 @@ func BuildDir(tpl string, g *eh.Gallery) string {
|
||||
return v
|
||||
}
|
||||
return m // 未知占位符原样保留,避免用户以为拼错了却静默消失
|
||||
})
|
||||
}
|
||||
|
||||
// 逐段清洗(兼容 / 和 \ 两种分隔符输入)
|
||||
// 先按模板里的分隔符切段,再逐段展开占位符
|
||||
segs := []string{}
|
||||
for _, s := range reIllegal.Split(out, -1) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
for _, raw := range reSep.Split(tpl, -1) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
continue
|
||||
}
|
||||
idx := len(segs)
|
||||
fb := "未命名"
|
||||
if idx == 0 {
|
||||
if len(segs) == 0 {
|
||||
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 {
|
||||
|
||||
@@ -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) {
|
||||
g := &eh.Gallery{GID: "12345678", Title: "Plain Title"}
|
||||
|
||||
Reference in New Issue
Block a user