commit 1d283d540c08c9ccb6dbadecae6cdb4d67316bb8 Author: Codex Date: Fri Apr 17 17:08:49 2026 +0800 Initial UNC file watcher web console diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d7c813 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.gocache/ +.gotmp/ +unc_tar_watcher.exe +unc_tar_watcher.exe~ +unc_tar_watcher_settings.json +*.part diff --git a/README.md b/README.md new file mode 100644 index 0000000..13cbc57 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# UNC File Watcher + +This project is a Go-based local web console for watching files in UNC paths and copying or moving them to local directories. + +After build, the program is a single `exe`. The target machine does not need Go installed. + +## Features + +The program supports two rule types at the same time: + +- Archive rule + Watches `.tar.gz` + Copies or moves files to a `zip` directory + Skips when the `zip` directory already has a file with the same name + Skips when the `unzip` directory already has an extracted folder with the same name + +- Generic file rule + Watches other file types by extension list + Only needs `UNC source path + local target path` + Does not use `unzip` + Supports two dedupe modes + +## Generic File Dedupe + +The generic file rule supports these dedupe modes: + +- `Same name and same size` + This is the default mode + If the target directory already has a file with the same name and the same size, the file is skipped + If the target directory already has a file with the same name but a different size, it is treated as a conflict and skipped without overwrite + +- `Same name and same hash` + When the target directory already has a file with the same name, the program computes SHA-256 for source and target + If the hashes match, the file is skipped + If the hashes do not match, it is treated as a conflict and skipped without overwrite + +## Stable File Detection + +To avoid processing a file while another service is still writing it in the UNC path, the program checks both conditions: + +- `size + modified time` stay unchanged across multiple scans +- the last modified time is older than `stable_for` + +Suggested starting values: + +```text +scan interval: 15s +stable_for: 1m +stable_scans: 3 +``` + +## Build + +```powershell +go build -o unc_tar_watcher.exe . +``` + +## Run + +```powershell +.\unc_tar_watcher.exe +``` + +Default listen address: + +```text +http://127.0.0.1:18080 +``` + +To change the port: + +```powershell +.\unc_tar_watcher.exe -listen 127.0.0.1:19090 +``` + +## Web UI Settings + +### Archive Rule + +- Enable archive rule +- Archive UNC source path +- Zip path +- Unzip path + +### Generic File Rule + +- Enable generic file rule +- Generic file UNC source path +- Local target path +- Extension list, for example `.csv,.txt,.pdf` +- Dedupe mode + +### Shared Settings + +- Scan interval +- Stable duration +- Stable scan count +- Transfer mode: `copy` or `move` +- Recursive scan + +## Local Settings File + +The web UI saves settings automatically next to the executable: + +```text +unc_tar_watcher_settings.json +``` + +When you open the page again, the last saved settings are loaded automatically. + +## Runtime Notes + +- In `copy` mode, source files remain in the UNC path +- In `move` mode, the program copies first and then removes the source file +- The generic file rule never overwrites an existing target file when a conflict is detected +- The archive rule only handles `.tar.gz` diff --git a/app.go b/app.go new file mode 100644 index 0000000..3a51d2e --- /dev/null +++ b/app.go @@ -0,0 +1,318 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "strings" + "time" +) + +func newApp() *app { + defaultCfg := defaultConfig() + defaultForm := formFromConfig(defaultCfg) + settingsPath := resolveSettingsPath() + + savedForm, err := loadSettings(settingsPath) + if err != nil { + log.Printf("load settings failed: %v", err) + } + + return &app{ + cfg: defaultCfg, + form: mergeForm(defaultForm, savedForm), + settingsPath: settingsPath, + lastError: loadSettingsErrorMessage(err), + } +} + +func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + a.renderPage(w, formValues{}, "", "") +} + +func (a *app) handleStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + form := formFromRequest(r) + cfg, err := parseConfig(form) + if err != nil { + a.renderPage(w, form, "", err.Error()) + return + } + + normalizedForm := formFromConfig(cfg) + saveErr := a.persistForm(normalizedForm) + + if err := a.startWatcher(cfg); err != nil { + a.renderPage(w, normalizedForm, "", joinMessages(err.Error(), saveErrorMessage(saveErr))) + return + } + + a.renderPage(w, normalizedForm, "监听任务已启动,配置已保存到本地", saveErrorMessage(saveErr)) +} + +func (a *app) handleStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + message := "当前没有正在运行的监听任务" + if a.stopWatcher() { + message = "已发送停止请求" + } + + a.renderPage(w, formValues{}, message, "") +} + +func (a *app) persistForm(form formValues) error { + a.mu.Lock() + a.form = form + a.mu.Unlock() + + if err := saveSettings(a.settingsPath, form); err != nil { + a.addLogf("save settings failed: %v", err) + return err + } + + a.mu.Lock() + if strings.HasPrefix(a.lastError, "读取本地配置失败") || strings.HasPrefix(a.lastError, "配置保存失败") { + a.lastError = "" + } + a.mu.Unlock() + return nil +} + +func (a *app) startWatcher(cfg config) error { + cfg, err := validateConfig(cfg) + if err != nil { + return err + } + + if cfg.archive.enabled { + if err := ensureExistingDir(cfg.archive.sourceDir); err != nil { + return fmt.Errorf("压缩包规则源路径错误: %w", err) + } + if err := ensureDir(cfg.archive.zipDir); err != nil { + return fmt.Errorf("压缩包规则 zip 路径错误: %w", err) + } + if err := ensureDir(cfg.archive.unzipDir); err != nil { + return fmt.Errorf("压缩包规则 unzip 路径错误: %w", err) + } + } + + if cfg.generic.enabled { + if err := ensureExistingDir(cfg.generic.sourceDir); err != nil { + return fmt.Errorf("普通文件规则源路径错误: %w", err) + } + if err := ensureDir(cfg.generic.targetDir); err != nil { + return fmt.Errorf("普通文件规则目标路径错误: %w", err) + } + } + + ctx, cancel := context.WithCancel(context.Background()) + + a.mu.Lock() + if a.cancel != nil { + a.cancel() + } + a.runID++ + runID := a.runID + a.cancel = cancel + a.running = true + a.startedAt = time.Now() + a.lastError = "" + a.lastMessage = "监听任务运行中" + a.cfg = cfg + a.form = formFromConfig(cfg) + a.mu.Unlock() + + if cfg.archive.enabled { + a.addLogf("archive watcher enabled: source=%q zip=%q unzip=%q", cfg.archive.sourceDir, cfg.archive.zipDir, cfg.archive.unzipDir) + } + if cfg.generic.enabled { + a.addLogf( + "generic watcher enabled: source=%q target=%q extensions=%s dedupe=%s", + cfg.generic.sourceDir, + cfg.generic.targetDir, + strings.Join(cfg.generic.extensions, ", "), + cfg.generic.dedupeMode, + ) + } + a.addLogf("common settings: interval=%s stable_for=%s stable_scans=%d mode=%s recursive=%t", + cfg.interval, cfg.stableFor, cfg.stableScans, cfg.mode, cfg.recursive) + + go a.runWatcher(ctx, runID, cfg) + return nil +} + +func (a *app) stopWatcher() bool { + a.mu.RLock() + cancel := a.cancel + running := a.running + a.mu.RUnlock() + + if cancel == nil || !running { + return false + } + + a.addLogf("stop requested from web UI") + cancel() + return true +} + +func (a *app) runWatcher(ctx context.Context, runID int64, cfg config) { + defer a.finishRun(runID) + + archiveState := make(map[string]fileState) + genericState := make(map[string]fileState) + + runScan := func() { + var errorsFound []string + + if cfg.archive.enabled { + err := scanRule( + "压缩包规则", + cfg.archive.sourceDir, + cfg.recursive, + func(name string) bool { + return strings.HasSuffix(strings.ToLower(name), tarGzSuffix) + }, + cfg, + archiveState, + func(sourcePath string) (string, bool, error) { + return processArchiveCandidate(cfg, sourcePath) + }, + a.addLogf, + ) + if err != nil { + errorsFound = append(errorsFound, "压缩包规则扫描失败: "+err.Error()) + } + } + + if cfg.generic.enabled { + err := scanRule( + "普通文件规则", + cfg.generic.sourceDir, + cfg.recursive, + func(name string) bool { + return matchesExtensions(name, cfg.generic.extensions) + }, + cfg, + genericState, + func(sourcePath string) (string, bool, error) { + return processGenericCandidate(cfg, sourcePath) + }, + a.addLogf, + ) + if err != nil { + errorsFound = append(errorsFound, "普通文件规则扫描失败: "+err.Error()) + } + } + + if len(errorsFound) > 0 { + message := strings.Join(errorsFound, ";") + a.setLastError(message) + a.addLogf("%s", message) + return + } + a.setLastError("") + } + + runScan() + + ticker := time.NewTicker(cfg.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + a.addLogf("watcher stopped") + return + case <-ticker.C: + runScan() + } + } +} + +func (a *app) finishRun(runID int64) { + a.mu.Lock() + defer a.mu.Unlock() + + if a.runID != runID { + return + } + + a.running = false + a.cancel = nil + a.lastMessage = "监听任务已停止" +} + +func (a *app) setLastError(message string) { + a.mu.Lock() + defer a.mu.Unlock() + a.lastError = message +} + +func (a *app) addLogf(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + log.Print(msg) + + line := fmt.Sprintf("%s %s", time.Now().Format("2006-01-02 15:04:05"), msg) + + a.mu.Lock() + defer a.mu.Unlock() + + a.logs = append(a.logs, line) + if len(a.logs) > maxLogLines { + a.logs = a.logs[len(a.logs)-maxLogLines:] + } +} + +func (a *app) renderPage(w http.ResponseWriter, override formValues, submitMessage, submitError string) { + page := a.snapshot() + if hasFormInput(override) { + page.Form = override + } + page.SubmitMessage = submitMessage + page.SubmitError = submitError + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pageTemplate.Execute(w, page); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func (a *app) snapshot() pageData { + a.mu.RLock() + defer a.mu.RUnlock() + + logs := append([]string(nil), a.logs...) + startedAt := "" + if !a.startedAt.IsZero() { + startedAt = a.startedAt.Format("2006-01-02 15:04:05") + } + + return pageData{ + Running: a.running, + StartedAt: startedAt, + LastMessage: a.lastMessage, + LastError: a.lastError, + SettingsPath: a.settingsPath, + Form: a.form, + Logs: logs, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f56b663 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module file_listening_tools + +go 1.26 diff --git a/main.go b/main.go new file mode 100644 index 0000000..8381a63 --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "log" + "net/http" +) + +func main() { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + + listenAddr := flag.String("listen", defaultListen, "web UI listen address, for example 127.0.0.1:18080") + flag.Parse() + + application := newApp() + + mux := http.NewServeMux() + mux.HandleFunc("/", application.handleIndex) + mux.HandleFunc("/start", application.handleStart) + mux.HandleFunc("/stop", application.handleStop) + + log.Printf("web UI listening at http://%s", *listenAddr) + log.Printf("open the address in your browser and configure the watcher there") + + if err := http.ListenAndServe(*listenAddr, mux); err != nil { + log.Fatalf("web server stopped: %v", err) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..3fe79d4 --- /dev/null +++ b/main_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +func TestExtractedDirName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "plain", in: "demo.tar.gz", want: "demo"}, + {name: "mixed case suffix", in: "Demo.TAR.GZ", want: "Demo"}, + {name: "other name", in: "demo.zip", want: "demo.zip"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := extractedDirName(tt.in) + if got != tt.want { + t.Fatalf("extractedDirName(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestIsStableEnough(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC) + cfg := config{ + stableFor: 30 * time.Second, + stableScans: 3, + } + + tests := []struct { + name string + stableCount int + modTime time.Time + want bool + }{ + {name: "not enough scans", stableCount: 2, modTime: now.Add(-1 * time.Minute), want: false}, + {name: "not enough age", stableCount: 3, modTime: now.Add(-20 * time.Second), want: false}, + {name: "enough scans and age", stableCount: 3, modTime: now.Add(-45 * time.Second), want: true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := isStableEnough(cfg, tt.stableCount, fileSnapshot{modTime: tt.modTime}, now) + if got != tt.want { + t.Fatalf("isStableEnough(...) = %t, want %t", got, tt.want) + } + }) + } +} + +func TestParseExtensions(t *testing.T) { + t.Parallel() + + got, err := parseExtensions(".csv, txt;PDF \n tar.gz") + if err != nil { + t.Fatalf("parseExtensions() error = %v", err) + } + + want := []string{".csv", ".txt", ".pdf", ".tar.gz"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseExtensions() = %#v, want %#v", got, want) + } +} + +func TestEvaluateGenericDuplicateNameSize(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + sourcePath := filepath.Join(dir, "source.csv") + targetPath := filepath.Join(dir, "target.csv") + + if err := os.WriteFile(sourcePath, []byte("abcd"), 0o644); err != nil { + t.Fatalf("write source error = %v", err) + } + if err := os.WriteFile(targetPath, []byte("wxyz"), 0o644); err != nil { + t.Fatalf("write target error = %v", err) + } + + decision, duplicate, err := evaluateGenericDuplicate(sourcePath, targetPath, genericDedupeNameSize) + if err != nil { + t.Fatalf("evaluateGenericDuplicate() error = %v", err) + } + if !duplicate { + t.Fatalf("expected same-size file to be treated as duplicate") + } + if decision == "" { + t.Fatalf("expected non-empty decision") + } +} + +func TestEvaluateGenericDuplicateNameHash(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + sourcePath := filepath.Join(dir, "source.csv") + targetPath := filepath.Join(dir, "target.csv") + + if err := os.WriteFile(sourcePath, []byte("same-content"), 0o644); err != nil { + t.Fatalf("write source error = %v", err) + } + if err := os.WriteFile(targetPath, []byte("same-content"), 0o644); err != nil { + t.Fatalf("write target error = %v", err) + } + + _, duplicate, err := evaluateGenericDuplicate(sourcePath, targetPath, genericDedupeNameHash) + if err != nil { + t.Fatalf("evaluateGenericDuplicate() error = %v", err) + } + if !duplicate { + t.Fatalf("expected same-hash file to be treated as duplicate") + } + + if err := os.WriteFile(targetPath, []byte("DIFF-content"), 0o644); err != nil { + t.Fatalf("rewrite target error = %v", err) + } + + _, duplicate, err = evaluateGenericDuplicate(sourcePath, targetPath, genericDedupeNameHash) + if err != nil { + t.Fatalf("evaluateGenericDuplicate() error = %v", err) + } + if duplicate { + t.Fatalf("expected different-hash file not to be treated as duplicate") + } +} + +func TestSaveAndLoadSettings(t *testing.T) { + t.Parallel() + + settingsPath := filepath.Join(t.TempDir(), "settings.json") + want := formValues{ + ArchiveEnabled: true, + ArchiveSourceDir: `\\server\share\incoming`, + ArchiveZipDir: `D:\zip`, + ArchiveUnzipDir: `D:\unzip`, + GenericEnabled: true, + GenericSourceDir: `\\server\share\others`, + GenericTargetDir: `D:\files`, + GenericExts: ".csv,.txt", + GenericDedupe: genericDedupeNameHash, + Interval: "15s", + StableFor: "1m", + StableScans: "3", + Mode: "move", + Recursive: true, + } + + if err := saveSettings(settingsPath, want); err != nil { + t.Fatalf("saveSettings() error = %v", err) + } + + got, err := loadSettings(settingsPath) + if err != nil { + t.Fatalf("loadSettings() error = %v", err) + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("loadSettings() = %#v, want %#v", got, want) + } +} + +func TestLoadLegacySettings(t *testing.T) { + t.Parallel() + + settingsPath := filepath.Join(t.TempDir(), "legacy.json") + legacyJSON := `{ + "source_dir": "\\\\server\\share\\incoming", + "zip_dir": "D:\\zip", + "unzip_dir": "D:\\unzip", + "interval": "15s", + "stable_for": "1m", + "stable_scans": "3", + "mode": "copy", + "recursive": true +}` + + if err := os.WriteFile(settingsPath, []byte(legacyJSON), 0o644); err != nil { + t.Fatalf("write legacy settings error = %v", err) + } + + got, err := loadSettings(settingsPath) + if err != nil { + t.Fatalf("loadSettings() error = %v", err) + } + + if !got.ArchiveEnabled { + t.Fatalf("expected archive rule to be enabled for legacy settings") + } + if got.ArchiveSourceDir != `\\server\share\incoming` { + t.Fatalf("unexpected archive source = %q", got.ArchiveSourceDir) + } + if got.GenericEnabled { + t.Fatalf("did not expect generic rule to be enabled for legacy settings") + } + if got.GenericDedupe != genericDedupeNameSize { + t.Fatalf("expected legacy settings to default to name_size dedupe") + } +} diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..ef94ea0 --- /dev/null +++ b/settings.go @@ -0,0 +1,408 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +func defaultConfig() config { + return config{ + interval: defaultInterval, + stableFor: defaultStableFor, + stableScans: 2, + mode: "copy", + generic: genericRule{ + dedupeMode: genericDedupeNameSize, + }, + } +} + +func resolveSettingsPath() string { + executablePath, err := os.Executable() + if err == nil { + return filepath.Join(filepath.Dir(executablePath), settingsFileName) + } + return settingsFileName +} + +func loadSettingsErrorMessage(err error) string { + if err == nil { + return "" + } + return fmt.Sprintf("读取本地配置失败: %v", err) +} + +func loadSettings(path string) (formValues, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return formValues{}, nil + } + return formValues{}, err + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return formValues{}, err + } + + if hasAnyLegacyKeys(raw) { + var legacy legacyFormValues + if err := json.Unmarshal(data, &legacy); err != nil { + return formValues{}, err + } + return formValues{ + ArchiveEnabled: legacy.SourceDir != "" || legacy.ZipDir != "" || legacy.UnzipDir != "", + ArchiveSourceDir: legacy.SourceDir, + ArchiveZipDir: legacy.ZipDir, + ArchiveUnzipDir: legacy.UnzipDir, + GenericDedupe: genericDedupeNameSize, + Interval: legacy.Interval, + StableFor: legacy.StableFor, + StableScans: legacy.StableScans, + Mode: legacy.Mode, + Recursive: legacy.Recursive, + }, nil + } + + var form formValues + if err := json.Unmarshal(data, &form); err != nil { + return formValues{}, err + } + + if _, ok := raw["archive_enabled"]; !ok && hasArchiveFields(form) { + form.ArchiveEnabled = true + } + if _, ok := raw["generic_enabled"]; !ok && hasGenericFields(form) { + form.GenericEnabled = true + } + if form.GenericDedupe == "" { + form.GenericDedupe = genericDedupeNameSize + } + + return form, nil +} + +func hasAnyLegacyKeys(raw map[string]json.RawMessage) bool { + _, sourceOK := raw["source_dir"] + _, zipOK := raw["zip_dir"] + _, unzipOK := raw["unzip_dir"] + return sourceOK || zipOK || unzipOK +} + +func saveSettings(path string, form formValues) error { + data, err := json.MarshalIndent(form, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func mergeForm(base, saved formValues) formValues { + if saved.ArchiveSourceDir != "" { + base.ArchiveSourceDir = saved.ArchiveSourceDir + } + if saved.ArchiveZipDir != "" { + base.ArchiveZipDir = saved.ArchiveZipDir + } + if saved.ArchiveUnzipDir != "" { + base.ArchiveUnzipDir = saved.ArchiveUnzipDir + } + if saved.GenericSourceDir != "" { + base.GenericSourceDir = saved.GenericSourceDir + } + if saved.GenericTargetDir != "" { + base.GenericTargetDir = saved.GenericTargetDir + } + if saved.GenericExts != "" { + base.GenericExts = saved.GenericExts + } + if saved.GenericDedupe != "" { + base.GenericDedupe = saved.GenericDedupe + } + if saved.Interval != "" { + base.Interval = saved.Interval + } + if saved.StableFor != "" { + base.StableFor = saved.StableFor + } + if saved.StableScans != "" { + base.StableScans = saved.StableScans + } + if saved.Mode != "" { + base.Mode = saved.Mode + } + base.ArchiveEnabled = saved.ArchiveEnabled + base.GenericEnabled = saved.GenericEnabled + base.Recursive = saved.Recursive + return base +} + +func formFromRequest(r *http.Request) formValues { + _ = r.ParseForm() + return formValues{ + ArchiveEnabled: r.FormValue("archive_enabled") != "", + ArchiveSourceDir: strings.TrimSpace(r.FormValue("archive_source")), + ArchiveZipDir: strings.TrimSpace(r.FormValue("archive_zip")), + ArchiveUnzipDir: strings.TrimSpace(r.FormValue("archive_unzip")), + GenericEnabled: r.FormValue("generic_enabled") != "", + GenericSourceDir: strings.TrimSpace(r.FormValue("generic_source")), + GenericTargetDir: strings.TrimSpace(r.FormValue("generic_target")), + GenericExts: strings.TrimSpace(r.FormValue("generic_exts")), + GenericDedupe: strings.TrimSpace(r.FormValue("generic_dedupe")), + Interval: strings.TrimSpace(r.FormValue("interval")), + StableFor: strings.TrimSpace(r.FormValue("stable_for")), + StableScans: strings.TrimSpace(r.FormValue("stable_scans")), + Mode: strings.TrimSpace(r.FormValue("mode")), + Recursive: r.FormValue("recursive") != "", + } +} + +func formFromConfig(cfg config) formValues { + return formValues{ + ArchiveEnabled: cfg.archive.enabled, + ArchiveSourceDir: cfg.archive.sourceDir, + ArchiveZipDir: cfg.archive.zipDir, + ArchiveUnzipDir: cfg.archive.unzipDir, + GenericEnabled: cfg.generic.enabled, + GenericSourceDir: cfg.generic.sourceDir, + GenericTargetDir: cfg.generic.targetDir, + GenericExts: strings.Join(cfg.generic.extensions, ","), + GenericDedupe: cfg.generic.dedupeMode, + Interval: cfg.interval.String(), + StableFor: cfg.stableFor.String(), + StableScans: strconv.Itoa(cfg.stableScans), + Mode: cfg.mode, + Recursive: cfg.recursive, + } +} + +func hasFormInput(form formValues) bool { + return form.ArchiveEnabled || + form.ArchiveSourceDir != "" || + form.ArchiveZipDir != "" || + form.ArchiveUnzipDir != "" || + form.GenericEnabled || + form.GenericSourceDir != "" || + form.GenericTargetDir != "" || + form.GenericExts != "" || + form.GenericDedupe != "" || + form.Interval != "" || + form.StableFor != "" || + form.StableScans != "" || + form.Mode != "" || + form.Recursive +} + +func hasArchiveFields(form formValues) bool { + return form.ArchiveSourceDir != "" || form.ArchiveZipDir != "" || form.ArchiveUnzipDir != "" +} + +func hasGenericFields(form formValues) bool { + return form.GenericSourceDir != "" || form.GenericTargetDir != "" || form.GenericExts != "" +} + +func parseConfig(form formValues) (config, error) { + cfg := defaultConfig() + + cfg.archive = archiveRule{ + enabled: form.ArchiveEnabled, + sourceDir: normalizePath(form.ArchiveSourceDir), + zipDir: normalizePath(form.ArchiveZipDir), + unzipDir: normalizePath(form.ArchiveUnzipDir), + } + cfg.generic = genericRule{ + enabled: form.GenericEnabled, + sourceDir: normalizePath(form.GenericSourceDir), + targetDir: normalizePath(form.GenericTargetDir), + dedupeMode: strings.ToLower(strings.TrimSpace(form.GenericDedupe)), + } + cfg.mode = strings.ToLower(strings.TrimSpace(form.Mode)) + cfg.recursive = form.Recursive + + if form.Interval != "" { + duration, err := time.ParseDuration(form.Interval) + if err != nil { + return cfg, fmt.Errorf("扫描间隔格式不正确: %w", err) + } + cfg.interval = duration + } + + if form.StableFor != "" { + duration, err := time.ParseDuration(form.StableFor) + if err != nil { + return cfg, fmt.Errorf("稳定时长格式不正确: %w", err) + } + cfg.stableFor = duration + } + + if form.StableScans != "" { + stableScans, err := strconv.Atoi(form.StableScans) + if err != nil { + return cfg, errors.New("稳定扫描次数必须是整数") + } + cfg.stableScans = stableScans + } + + if form.GenericExts != "" { + extensions, err := parseExtensions(form.GenericExts) + if err != nil { + return cfg, err + } + cfg.generic.extensions = extensions + } + + return validateConfig(cfg) +} + +func validateConfig(cfg config) (config, error) { + if cfg.mode == "" { + cfg.mode = "copy" + } + if cfg.generic.dedupeMode == "" { + cfg.generic.dedupeMode = genericDedupeNameSize + } + + switch cfg.mode { + case "copy", "move": + default: + return cfg, fmt.Errorf("不支持的处理方式 %q", cfg.mode) + } + + switch cfg.generic.dedupeMode { + case genericDedupeNameSize, genericDedupeNameHash: + default: + return cfg, fmt.Errorf("不支持的普通文件去重方式 %q", cfg.generic.dedupeMode) + } + + if cfg.interval <= 0 { + return cfg, errors.New("扫描间隔必须大于 0") + } + if cfg.stableFor < 0 { + return cfg, errors.New("稳定时长不能小于 0") + } + if cfg.stableScans < 1 { + return cfg, errors.New("稳定扫描次数必须至少为 1") + } + if !cfg.archive.enabled && !cfg.generic.enabled { + return cfg, errors.New("至少启用一条监听规则") + } + + if cfg.archive.enabled { + if cfg.archive.sourceDir == "" { + return cfg, errors.New("压缩包规则的 UNC 源路径不能为空") + } + if cfg.archive.zipDir == "" { + return cfg, errors.New("压缩包规则的 zip 路径不能为空") + } + if cfg.archive.unzipDir == "" { + return cfg, errors.New("压缩包规则的 unzip 路径不能为空") + } + if cfg.archive.sourceDir == cfg.archive.zipDir { + return cfg, errors.New("压缩包规则的源路径和 zip 路径不能相同") + } + if cfg.archive.sourceDir == cfg.archive.unzipDir { + return cfg, errors.New("压缩包规则的源路径和 unzip 路径不能相同") + } + if cfg.archive.zipDir == cfg.archive.unzipDir { + return cfg, errors.New("压缩包规则的 zip 路径和 unzip 路径不能相同") + } + } + + if cfg.generic.enabled { + if cfg.generic.sourceDir == "" { + return cfg, errors.New("普通文件规则的 UNC 源路径不能为空") + } + if cfg.generic.targetDir == "" { + return cfg, errors.New("普通文件规则的目标路径不能为空") + } + if cfg.generic.sourceDir == cfg.generic.targetDir { + return cfg, errors.New("普通文件规则的源路径和目标路径不能相同") + } + if len(cfg.generic.extensions) == 0 { + return cfg, errors.New("普通文件规则的扩展名列表不能为空") + } + } + + return cfg, nil +} + +func parseExtensions(raw string) ([]string, error) { + parts := strings.FieldsFunc(raw, func(r rune) bool { + switch r { + case ',', ';', '\n', '\r', '\t', ' ': + return true + default: + return false + } + }) + + seen := make(map[string]struct{}) + extensions := make([]string, 0, len(parts)) + + for _, part := range parts { + item := strings.ToLower(strings.TrimSpace(part)) + item = strings.TrimPrefix(item, "*") + if item == "" { + continue + } + if !strings.HasPrefix(item, ".") { + item = "." + item + } + if item == "." { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + extensions = append(extensions, item) + } + + if len(extensions) == 0 { + return nil, errors.New("普通文件规则的扩展名列表不能为空") + } + return extensions, nil +} + +func matchesExtensions(name string, extensions []string) bool { + lowerName := strings.ToLower(name) + for _, ext := range extensions { + if strings.HasSuffix(lowerName, ext) { + return true + } + } + return false +} + +func normalizePath(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + return filepath.Clean(raw) +} + +func joinMessages(primary, secondary string) string { + if primary == "" { + return secondary + } + if secondary == "" { + return primary + } + return primary + ";" + secondary +} + +func saveErrorMessage(err error) string { + if err == nil { + return "" + } + return fmt.Sprintf("配置保存失败: %v", err) +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..f5ab566 --- /dev/null +++ b/types.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "os" + "sync" + "time" +) + +const ( + tarGzSuffix = ".tar.gz" + defaultListen = "127.0.0.1:18080" + defaultInterval = 15 * time.Second + defaultStableFor = 30 * time.Second + maxLogLines = 200 + settingsFileName = "unc_tar_watcher_settings.json" + genericDedupeNameSize = "name_size" + genericDedupeNameHash = "name_hash" +) + +type archiveRule struct { + enabled bool + sourceDir string + zipDir string + unzipDir string +} + +type genericRule struct { + enabled bool + sourceDir string + targetDir string + extensions []string + dedupeMode string +} + +type config struct { + interval time.Duration + stableFor time.Duration + stableScans int + mode string + recursive bool + archive archiveRule + generic genericRule +} + +type fileSnapshot struct { + size int64 + modTime time.Time +} + +type fileState struct { + lastObserved fileSnapshot + stableCount int + handledKey string + lastDecision string +} + +type candidate struct { + path string + info os.FileInfo +} + +type formValues struct { + ArchiveEnabled bool `json:"archive_enabled"` + ArchiveSourceDir string `json:"archive_source_dir"` + ArchiveZipDir string `json:"archive_zip_dir"` + ArchiveUnzipDir string `json:"archive_unzip_dir"` + GenericEnabled bool `json:"generic_enabled"` + GenericSourceDir string `json:"generic_source_dir"` + GenericTargetDir string `json:"generic_target_dir"` + GenericExts string `json:"generic_exts"` + GenericDedupe string `json:"generic_dedupe"` + Interval string `json:"interval"` + StableFor string `json:"stable_for"` + StableScans string `json:"stable_scans"` + Mode string `json:"mode"` + Recursive bool `json:"recursive"` +} + +type legacyFormValues struct { + SourceDir string `json:"source_dir"` + ZipDir string `json:"zip_dir"` + UnzipDir string `json:"unzip_dir"` + Interval string `json:"interval"` + StableFor string `json:"stable_for"` + StableScans string `json:"stable_scans"` + Mode string `json:"mode"` + Recursive bool `json:"recursive"` +} + +type pageData struct { + Running bool + StartedAt string + LastMessage string + LastError string + SubmitMessage string + SubmitError string + SettingsPath string + Form formValues + Logs []string +} + +type app struct { + mu sync.RWMutex + cancel context.CancelFunc + runID int64 + running bool + startedAt time.Time + lastMessage string + lastError string + cfg config + form formValues + logs []string + settingsPath string +} + +type processFunc func(string) (string, bool, error) diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..a343688 --- /dev/null +++ b/ui.go @@ -0,0 +1,361 @@ +package main + +import "html/template" + +var pageTemplate = template.Must(template.New("page").Parse(` + + + + + UNC 文件监听控制台 + + + +
+
+

UNC 文件监听控制台

+

支持两类规则同时运行。压缩包规则专门处理 .tar.gz 并结合 zip / unzip 目录去重;普通文件规则按扩展名列表把文件从 UNC 路径复制或剪切到本机目录,并支持按“同名+大小”或“同名+哈希”去重。

+
+ +
+
+

监听配置

+ {{if .SubmitMessage}}
{{.SubmitMessage}}
{{end}} + {{if .SubmitError}}
{{.SubmitError}}
{{end}} + {{if .LastError}}
{{.LastError}}
{{end}} +
+
+ +
+ + + +
+
+ +
+ +
+ + + + +
+
+ +
+

公共设置

+
+ + + + +
+ + +
+ +

页面配置会自动保存到 {{.SettingsPath}},下次打开页面会自动带出。

+ +
+ +
+
+
+ +
+

运行状态

+
+
+ {{if .Running}}运行中{{else}}未运行{{end}} +
+ {{if .StartedAt}} +
启动时间 {{.StartedAt}}
+ {{end}} +
+ {{if .LastMessage}} +

{{.LastMessage}}

+ {{end}} +

如果 UNC 目录中的文件由后台服务持续写入,建议把“稳定时长”和“稳定扫描次数”设得更保守一些,例如 stable_for=1mstable_scans=3

+

普通文件规则里如果目标目录出现同名但不同大小,或者同名但哈希不同,程序会记录为冲突并跳过,不会覆盖已有文件。

+
+
+ +
+
+
+
+ +
+

最近日志

+
{{if .Logs}}{{range .Logs}}{{.}} +{{end}}{{else}}暂无日志。启动监听后,这里会显示文件发现、跳过、复制、剪切和错误信息。{{end}}
+
+
+ +`)) diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..8ac3f85 --- /dev/null +++ b/watcher.go @@ -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() +}