Initial UNC file watcher web console

This commit is contained in:
Codex
2026-04-17 17:08:49 +08:00
commit 1d283d540c
10 changed files with 1931 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.gocache/
.gotmp/
unc_tar_watcher.exe
unc_tar_watcher.exe~
unc_tar_watcher_settings.json
*.part
+116
View File
@@ -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`
+318
View File
@@ -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,
}
}
+3
View File
@@ -0,0 +1,3 @@
module file_listening_tools
go 1.26
+28
View File
@@ -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)
}
}
+214
View File
@@ -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")
}
}
+408
View File
@@ -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)
}
+117
View File
@@ -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)
+361
View File
@@ -0,0 +1,361 @@
package main
import "html/template"
var pageTemplate = template.Must(template.New("page").Parse(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UNC 文件监听控制台</title>
<style>
:root {
--bg: #f3efe7;
--panel: #fffaf2;
--panel-strong: #f7efe1;
--ink: #1f2520;
--muted: #6b716a;
--accent: #205c43;
--accent-soft: #dcebdd;
--warn: #8a3b2f;
--warn-soft: #f7ddd8;
--line: #d8cfbf;
--shadow: rgba(31, 37, 32, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(32, 92, 67, 0.10), transparent 28%),
linear-gradient(180deg, #f8f4ec 0%, var(--bg) 100%);
color: var(--ink);
}
.shell {
width: min(1180px, calc(100% - 32px));
margin: 24px auto 40px;
}
.hero {
background: linear-gradient(135deg, #214d3a 0%, #2f6a54 100%);
color: #f8f5ee;
border-radius: 24px;
padding: 28px;
box-shadow: 0 20px 40px var(--shadow);
}
.hero h1 {
margin: 0 0 10px;
font-size: clamp(28px, 5vw, 42px);
line-height: 1.05;
}
.hero p {
margin: 0;
max-width: 860px;
color: rgba(248, 245, 238, 0.85);
line-height: 1.7;
}
.grid {
display: grid;
grid-template-columns: 1.35fr 0.65fr;
gap: 18px;
margin-top: 18px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: 22px;
box-shadow: 0 14px 30px var(--shadow);
}
.panel h2 {
margin: 0 0 16px;
font-size: 20px;
}
.section {
border: 1px solid var(--line);
border-radius: 18px;
padding: 16px;
background: #fffdf8;
}
.section + .section {
margin-top: 14px;
}
.section h3 {
margin: 0 0 12px;
font-size: 16px;
}
.status-line {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
background: var(--panel-strong);
border: 1px solid var(--line);
font-weight: 600;
}
.pill.running {
background: var(--accent-soft);
color: var(--accent);
border-color: rgba(32, 92, 67, 0.2);
}
.note {
margin: 10px 0 0;
color: var(--muted);
line-height: 1.7;
}
.flash {
padding: 12px 14px;
border-radius: 16px;
margin-bottom: 14px;
line-height: 1.6;
border: 1px solid transparent;
}
.flash.ok {
background: var(--accent-soft);
color: var(--accent);
border-color: rgba(32, 92, 67, 0.18);
}
.flash.err {
background: var(--warn-soft);
color: var(--warn);
border-color: rgba(138, 59, 47, 0.18);
}
form {
display: grid;
gap: 14px;
}
.fields {
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fields .wide {
grid-column: 1 / -1;
}
label {
display: grid;
gap: 8px;
font-size: 14px;
font-weight: 600;
}
label span {
color: var(--muted);
font-weight: 500;
font-size: 13px;
}
input, select {
width: 100%;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: #fffdf8;
color: var(--ink);
font: inherit;
}
.checkbox {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
margin-bottom: 12px;
}
.checkbox input {
width: 18px;
height: 18px;
margin: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 6px;
}
button {
appearance: none;
border: 0;
border-radius: 999px;
padding: 12px 20px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.primary {
background: var(--accent);
color: #f7f3ec;
}
.secondary {
background: #e7dfd0;
color: #493c2c;
}
.logs {
margin-top: 18px;
}
.logbox {
border-radius: 18px;
border: 1px solid var(--line);
background: #1f2520;
color: #dbe5dc;
padding: 16px;
min-height: 320px;
max-height: 560px;
overflow: auto;
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.empty {
color: #9cab9e;
}
code {
font-family: Consolas, "Courier New", monospace;
}
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
}
.fields {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<h1>UNC 文件监听控制台</h1>
<p>支持两类规则同时运行。压缩包规则专门处理 <code>.tar.gz</code> 并结合 zip / unzip 目录去重;普通文件规则按扩展名列表把文件从 UNC 路径复制或剪切到本机目录,并支持按“同名+大小”或“同名+哈希”去重。</p>
</section>
<section class="grid">
<div class="panel">
<h2>监听配置</h2>
{{if .SubmitMessage}}<div class="flash ok">{{.SubmitMessage}}</div>{{end}}
{{if .SubmitError}}<div class="flash err">{{.SubmitError}}</div>{{end}}
{{if .LastError}}<div class="flash err">{{.LastError}}</div>{{end}}
<form method="post" action="/start">
<div class="section">
<label class="checkbox">
<input type="checkbox" name="archive_enabled" {{if .Form.ArchiveEnabled}}checked{{end}}>
启用压缩包规则
</label>
<div class="fields">
<label class="wide">压缩包 UNC 源路径
<span>例如 \\server\share\incoming,仅处理 .tar.gz</span>
<input name="archive_source" value="{{.Form.ArchiveSourceDir}}" placeholder="\\server\share\incoming">
</label>
<label>zip 路径
<span>需要复制或剪切到这里</span>
<input name="archive_zip" value="{{.Form.ArchiveZipDir}}" placeholder="D:\zip">
</label>
<label>unzip 路径
<span>已有同名解压目录则跳过</span>
<input name="archive_unzip" value="{{.Form.ArchiveUnzipDir}}" placeholder="D:\unzip">
</label>
</div>
</div>
<div class="section">
<label class="checkbox">
<input type="checkbox" name="generic_enabled" {{if .Form.GenericEnabled}}checked{{end}}>
启用普通文件规则
</label>
<div class="fields">
<label class="wide">普通文件 UNC 源路径
<span>例如 \\server\share\others</span>
<input name="generic_source" value="{{.Form.GenericSourceDir}}" placeholder="\\server\share\others">
</label>
<label>本机目标路径
<span>只处理到这个目录,不检查 unzip</span>
<input name="generic_target" value="{{.Form.GenericTargetDir}}" placeholder="D:\other_files">
</label>
<label>扩展名列表
<span>例如 .csv,.txt,.pdf 或 xlsx,docx</span>
<input name="generic_exts" value="{{.Form.GenericExts}}" placeholder=".csv,.txt,.pdf">
</label>
<label>去重方式
<span>目标目录已有同名文件时使用</span>
<select name="generic_dedupe">
<option value="name_size" {{if eq .Form.GenericDedupe "name_size"}}selected{{end}}>同名且大小相同才跳过</option>
<option value="name_hash" {{if eq .Form.GenericDedupe "name_hash"}}selected{{end}}>同名且哈希相同才跳过</option>
</select>
</label>
</div>
</div>
<div class="section">
<h3>公共设置</h3>
<div class="fields">
<label>扫描间隔
<span>支持 Go duration,如 15s、1m</span>
<input name="interval" value="{{.Form.Interval}}" placeholder="15s">
</label>
<label>稳定时长
<span>最后修改时间至少早于当前多久</span>
<input name="stable_for" value="{{.Form.StableFor}}" placeholder="30s">
</label>
<label>稳定扫描次数
<span>连续几次扫描未变化才处理</span>
<input name="stable_scans" value="{{.Form.StableScans}}" placeholder="2">
</label>
<label>处理方式
<span>copy 复制,move 剪切</span>
<select name="mode">
<option value="copy" {{if eq .Form.Mode "copy"}}selected{{end}}>copy</option>
<option value="move" {{if eq .Form.Mode "move"}}selected{{end}}>move</option>
</select>
</label>
</div>
<label class="checkbox" style="margin-top:12px;">
<input type="checkbox" name="recursive" {{if .Form.Recursive}}checked{{end}}>
递归扫描源目录
</label>
</div>
<p class="note">页面配置会自动保存到 <code>{{.SettingsPath}}</code>,下次打开页面会自动带出。</p>
<div class="actions">
<button class="primary" type="submit">启动或重启监听</button>
</div>
</form>
</div>
<div class="panel">
<h2>运行状态</h2>
<div class="status-line">
<div class="pill {{if .Running}}running{{end}}">
{{if .Running}}运行中{{else}}未运行{{end}}
</div>
{{if .StartedAt}}
<div class="pill">启动时间 {{.StartedAt}}</div>
{{end}}
</div>
{{if .LastMessage}}
<p class="note">{{.LastMessage}}</p>
{{end}}
<p class="note">如果 UNC 目录中的文件由后台服务持续写入,建议把“稳定时长”和“稳定扫描次数”设得更保守一些,例如 <code>stable_for=1m</code>、<code>stable_scans=3</code>。</p>
<p class="note">普通文件规则里如果目标目录出现同名但不同大小,或者同名但哈希不同,程序会记录为冲突并跳过,不会覆盖已有文件。</p>
<form method="post" action="/stop">
<div class="actions">
<button class="secondary" type="submit">停止监听</button>
</div>
</form>
</div>
</section>
<section class="panel logs">
<h2>最近日志</h2>
<div class="logbox">{{if .Logs}}{{range .Logs}}{{.}}
{{end}}{{else}}<span class="empty">暂无日志。启动监听后,这里会显示文件发现、跳过、复制、剪切和错误信息。</span>{{end}}</div>
</section>
</main>
</body>
</html>`))
+360
View File
@@ -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()
}