现象:默认模板 {title} 本该只出一层目录,但标题里带 | / : 的画廊
会凭空多出一层文件夹(6 个里 5 个中招)。
根因:把「非法文件名字符集」当成了「目录分隔符集」。两者只有 \ 和 /
重叠,剩下 : * ? " < > | 这 6 个字符被完全误当成目录分隔符。
更糟的是展开顺序——旧代码先把占位符全部替换完再切分,切分对象是
替换后的完整字符串,模板里的分隔符和值里的字符混在一起根本分不开。
EH 标题惯例是「原文 | 译名」,竖线极常见,所以中招率很高。
改动:改为先按模板文本切段(新增 reSep),再逐段展开占位符;
值里的非法字符一律交给 SanitizeSegment 清洗成 _,不再产生层级。
模板里自己写的 / 仍然正常分层,{artist}/{title} 行为不变。
顺带一提:值里的非法字符以前是「变成目录」,现在才是本该有的
「清洗成 _」——两处回归测试都锁住了。
267 lines
7.2 KiB
Go
267 lines
7.2 KiB
Go
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
|
||
}
|