Initial UNC file watcher web console
This commit is contained in:
+360
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ensureExistingDir(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%q 不是目录", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDir(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil {
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%q 不是目录", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(path, 0o755)
|
||||
}
|
||||
|
||||
func scanRule(
|
||||
ruleName string,
|
||||
root string,
|
||||
recursive bool,
|
||||
matches func(string) bool,
|
||||
cfg config,
|
||||
state map[string]fileState,
|
||||
process processFunc,
|
||||
logf func(string, ...any),
|
||||
) error {
|
||||
entries, err := collectCandidates(root, recursive, matches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
seen := make(map[string]struct{}, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
sourcePath := entry.path
|
||||
seen[sourcePath] = struct{}{}
|
||||
|
||||
snap := fileSnapshot{
|
||||
size: entry.info.Size(),
|
||||
modTime: entry.info.ModTime(),
|
||||
}
|
||||
key := snapshotKey(snap)
|
||||
|
||||
current := state[sourcePath]
|
||||
if current.lastObserved != snap {
|
||||
state[sourcePath] = fileState{
|
||||
lastObserved: snap,
|
||||
stableCount: 1,
|
||||
}
|
||||
logf("[%s] 发现新文件或文件有变化: %s", ruleName, sourcePath)
|
||||
continue
|
||||
}
|
||||
|
||||
if current.handledKey == key {
|
||||
continue
|
||||
}
|
||||
|
||||
current.stableCount++
|
||||
current.lastObserved = snap
|
||||
state[sourcePath] = current
|
||||
|
||||
if !isStableEnough(cfg, current.stableCount, snap, now) {
|
||||
continue
|
||||
}
|
||||
|
||||
decision, handled, err := process(sourcePath)
|
||||
if err != nil {
|
||||
logf("[%s] 处理失败: %s, err=%v", ruleName, sourcePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if handled {
|
||||
current.handledKey = key
|
||||
} else {
|
||||
current.handledKey = ""
|
||||
}
|
||||
if decision != current.lastDecision {
|
||||
logf("[%s] %s: %s", ruleName, decision, sourcePath)
|
||||
}
|
||||
current.lastDecision = decision
|
||||
state[sourcePath] = current
|
||||
}
|
||||
|
||||
for path := range state {
|
||||
if _, ok := seen[path]; !ok {
|
||||
delete(state, path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectCandidates(root string, recursive bool, matches func(string) bool) ([]candidate, error) {
|
||||
if recursive {
|
||||
var items []candidate
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !matches(info.Name()) {
|
||||
return nil
|
||||
}
|
||||
items = append(items, candidate{path: path, info: info})
|
||||
return nil
|
||||
})
|
||||
return items, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]candidate, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !matches(name) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, candidate{
|
||||
path: filepath.Join(root, name),
|
||||
info: info,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func processArchiveCandidate(cfg config, sourcePath string) (string, bool, error) {
|
||||
fileName := filepath.Base(sourcePath)
|
||||
zipTarget := filepath.Join(cfg.archive.zipDir, fileName)
|
||||
|
||||
if pathExists(zipTarget) {
|
||||
return "已跳过,zip 目录中存在同名压缩包", false, nil
|
||||
}
|
||||
|
||||
unzipTarget := filepath.Join(cfg.archive.unzipDir, extractedDirName(fileName))
|
||||
if isDir(unzipTarget) {
|
||||
return "已跳过,unzip 目录中存在同名解压目录", false, nil
|
||||
}
|
||||
|
||||
if err := transferFile(sourcePath, zipTarget, cfg.mode); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if cfg.mode == "move" {
|
||||
return "已剪切到 zip 目录", true, nil
|
||||
}
|
||||
return "已复制到 zip 目录", true, nil
|
||||
}
|
||||
|
||||
func processGenericCandidate(cfg config, sourcePath string) (string, bool, error) {
|
||||
fileName := filepath.Base(sourcePath)
|
||||
targetPath := filepath.Join(cfg.generic.targetDir, fileName)
|
||||
|
||||
if pathExists(targetPath) {
|
||||
decision, duplicate, err := evaluateGenericDuplicate(sourcePath, targetPath, cfg.generic.dedupeMode)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if duplicate {
|
||||
return decision, false, nil
|
||||
}
|
||||
return decision, false, nil
|
||||
}
|
||||
|
||||
if err := transferFile(sourcePath, targetPath, cfg.mode); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if cfg.mode == "move" {
|
||||
return "已剪切到普通文件目标目录", true, nil
|
||||
}
|
||||
return "已复制到普通文件目标目录", true, nil
|
||||
}
|
||||
|
||||
func evaluateGenericDuplicate(sourcePath, targetPath, dedupeMode string) (string, bool, error) {
|
||||
sourceInfo, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("stat source: %w", err)
|
||||
}
|
||||
|
||||
targetInfo, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("stat target: %w", err)
|
||||
}
|
||||
|
||||
switch dedupeMode {
|
||||
case genericDedupeNameHash:
|
||||
if sourceInfo.Size() != targetInfo.Size() {
|
||||
return "已跳过,同名文件已存在但大小不同", false, nil
|
||||
}
|
||||
same, err := filesHaveSameHash(sourcePath, targetPath)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if same {
|
||||
return "已跳过,同名文件已存在且哈希相同", true, nil
|
||||
}
|
||||
return "已跳过,同名文件已存在但哈希不同", false, nil
|
||||
|
||||
case genericDedupeNameSize:
|
||||
fallthrough
|
||||
default:
|
||||
if sourceInfo.Size() == targetInfo.Size() {
|
||||
return "已跳过,同名文件已存在且大小相同", true, nil
|
||||
}
|
||||
return "已跳过,同名文件已存在但大小不同", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func filesHaveSameHash(sourcePath, targetPath string) (bool, error) {
|
||||
sourceHash, err := fileSHA256(sourcePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("hash source: %w", err)
|
||||
}
|
||||
|
||||
targetHash, err := fileSHA256(targetPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("hash target: %w", err)
|
||||
}
|
||||
|
||||
return sourceHash == targetHash, nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) ([32]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return [32]byte{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return [32]byte{}, err
|
||||
}
|
||||
|
||||
sumBytes := hasher.Sum(nil)
|
||||
var sum [32]byte
|
||||
copy(sum[:], sumBytes)
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
func transferFile(sourcePath, targetPath, mode string) error {
|
||||
if pathExists(targetPath) {
|
||||
return fmt.Errorf("target already exists: %s", targetPath)
|
||||
}
|
||||
|
||||
tempPath := targetPath + ".part"
|
||||
if pathExists(tempPath) {
|
||||
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove stale temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
target, err := os.OpenFile(tempPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp target: %w", err)
|
||||
}
|
||||
|
||||
copyErr := copyFileContents(target, source)
|
||||
closeErr := target.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return fmt.Errorf("close temp target: %w", closeErr)
|
||||
}
|
||||
|
||||
if err := os.Rename(tempPath, targetPath); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return fmt.Errorf("rename temp target: %w", err)
|
||||
}
|
||||
|
||||
if mode == "move" {
|
||||
if err := os.Remove(sourcePath); err != nil {
|
||||
return fmt.Errorf("remove source after move: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFileContents(target *os.File, source *os.File) error {
|
||||
if _, err := io.Copy(target, source); err != nil {
|
||||
return fmt.Errorf("copy data: %w", err)
|
||||
}
|
||||
if err := target.Sync(); err != nil {
|
||||
return fmt.Errorf("flush target: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractedDirName(fileName string) string {
|
||||
lowerName := strings.ToLower(fileName)
|
||||
if strings.HasSuffix(lowerName, tarGzSuffix) {
|
||||
return fileName[:len(fileName)-len(tarGzSuffix)]
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
func snapshotKey(s fileSnapshot) string {
|
||||
return fmt.Sprintf("%d:%d", s.size, s.modTime.UnixNano())
|
||||
}
|
||||
|
||||
func isStableEnough(cfg config, stableCount int, snap fileSnapshot, now time.Time) bool {
|
||||
if stableCount < cfg.stableScans {
|
||||
return false
|
||||
}
|
||||
return now.Sub(snap.modTime) >= cfg.stableFor
|
||||
}
|
||||
|
||||
func pathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func isDir(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
Reference in New Issue
Block a user