package common import ( "bytes" "crypto/sha256" "fmt" "io" "os" "path/filepath" "regexp" "runtime" "strings" "sync" "unicode/utf8" "golang.org/x/text/encoding/simplifiedchinese" "golang.org/x/text/transform" ) // InterfaceSliceToStringSlice 将 []interface{} 安全转换为 []string func InterfaceSliceToStringSlice(slice []interface{}) []string { result := make([]string, 0, len(slice)) for _, v := range slice { if str, ok := v.(string); ok && str != "" { result = append(result, str) } } return result } // FormatBytes 格式化字节大小为人类可读格式(uint64 版本) func FormatBytes(bytes uint64) string { const unit = 1024 if bytes < unit { return fmt.Sprintf("%d B", bytes) } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.2f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } // FormatBytesInt 格式化字节大小(int64 版本,用于文件大小) func FormatBytesInt(bytes int64) string { return FormatBytes(uint64(bytes)) } // CopyFile 复制文件内容 func CopyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = out.ReadFrom(in) return err } // BytesToString 智能编码转换:UTF-8 直接返回,否则尝试 GBK → UTF-8 func BytesToString(data []byte) string { if utf8.Valid(data) { return string(data) } reader := transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder()) decoded, err := io.ReadAll(reader) if err != nil || !utf8.Valid(decoded) { return string(data) } return string(decoded) } var ( htmlResRegex = regexp.MustCompile(`(?:src|href|data-src|data-url|poster)=["']([^"']+)["']`) htmlSrcsetRegex = regexp.MustCompile(`srcset\s*=\s*["']([^"']+)["']`) htmlCssUrlRe = regexp.MustCompile(`url\(\s*["']?([^"')]+)["']?\s*\)`) ) // parseSrcsetUrls 从 srcset 属性值中提取各 URL // srcset 格式: "small.jpg 480w, large.jpg 1024w" → ["small.jpg", "large.jpg"] func parseSrcsetUrls(srcset string) []string { var urls []string for _, candidate := range strings.Split(srcset, ",") { parts := strings.Fields(strings.TrimSpace(candidate)) if len(parts) > 0 { urls = append(urls, parts[0]) } } return urls } // ExtractHtmlResources 从 HTML 内容提取资源路径 func ExtractHtmlResources(html string) []string { seen := make(map[string]bool) var resources []string add := func(v string) { v = strings.TrimSpace(v) if v != "" && !seen[v] { seen[v] = true resources = append(resources, v) } } for _, m := range htmlResRegex.FindAllStringSubmatch(html, -1) { if len(m) > 1 { add(m[1]) } } for _, m := range htmlSrcsetRegex.FindAllStringSubmatch(html, -1) { if len(m) > 1 { for _, u := range parseSrcsetUrls(m[1]) { add(u) } } } for _, m := range htmlCssUrlRe.FindAllStringSubmatch(html, -1) { if len(m) > 1 { add(m[1]) } } return resources } // ShouldSkipResource 判断资源路径是否应跳过 func ShouldSkipResource(p string) bool { return strings.HasPrefix(p, "data:") || strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") || strings.HasPrefix(p, "//") || strings.HasPrefix(p, "#") || strings.HasPrefix(p, "javascript:") || strings.HasPrefix(p, "mailto:") || strings.HasPrefix(p, "blob:") } // Contains 检查切片是否包含元素 func Contains[T comparable](slice []T, item T) bool { for _, s := range slice { if s == item { return true } } return false } // Difference 返回在 a 中但不在 b 中的元素 func Difference[T comparable](a, b []T) []T { mb := make(map[T]struct{}, len(b)) for _, x := range b { mb[x] = struct{}{} } var diff []T for _, x := range a { if _, found := mb[x]; !found { diff = append(diff, x) } } return diff } // IsWindows 判断是否为Windows系统 func IsWindows() bool { return runtime.GOOS == "windows" } // siteCacheDir 返回站点预览的确定性缓存目录路径 func SiteCacheDir(transport, connID, remotePath string, fileSize int64, modTime string) string { h := sha256.New() h.Write([]byte(fmt.Sprintf("site:%s:%s:%s:%d:%s", transport, connID, remotePath, fileSize, modTime))) hash := fmt.Sprintf("%x", h.Sum(nil))[:16] return filepath.Join(os.TempDir(), "u-desk-cache", "site-"+hash) } // 站点预览补充下载限制(sftp 与 ossdrv 共用) const ( MaxSupplementFiles = 50 // 每目录补充下载文件数上限 MaxSupplementFileSize = 5 << 20 // 单文件补充下载大小上限(5MB) SiteDownloadConcurrency = 8 // 相对路径资源并行下载并发数 ) // RunConcurrent 以最多 limit 个并发执行 tasks,全部完成后返回 // fn 在独立 goroutine 中运行,需自行处理错误与共享状态的加锁 func RunConcurrent[T any](tasks []T, limit int, fn func(t T)) { if len(tasks) == 0 || limit <= 0 { return } if limit > len(tasks) { limit = len(tasks) } sem := make(chan struct{}, limit) var wg sync.WaitGroup for _, task := range tasks { sem <- struct{}{} wg.Add(1) go func(t T) { defer func() { <-sem; wg.Done() }() fn(t) }(task) } wg.Wait() } // UniqueName 返回不与现有条目冲突的文件名:重名时追加「 - 副本」「 - 副本 (2)」后缀(扩展名前插入)。 // exists 报告候选文件名是否已被占用(本地文件系统与 SFTP 各自提供实现)。 func UniqueName(name string, exists func(candidate string) (bool, error)) (string, error) { taken, err := exists(name) if err != nil { return "", err } if !taken { return name, nil } ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) for i := 1; ; i++ { suffix := " - 副本" if i > 1 { suffix = fmt.Sprintf(" - 副本 (%d)", i) } candidate := base + suffix + ext taken, err := exists(candidate) if err != nil { return "", err } if !taken { return candidate, nil } } } // CountingReader 包装读取器,按实际读取字节数回调 OnN(n > 0 时) type CountingReader struct { R io.Reader OnN func(n int64) } func (cr *CountingReader) Read(buf []byte) (int, error) { n, err := cr.R.Read(buf) if n > 0 { cr.OnN(int64(n)) } return n, err }