Initial UNC file watcher web console
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.gocache/
|
||||
.gotmp/
|
||||
unc_tar_watcher.exe
|
||||
unc_tar_watcher.exe~
|
||||
unc_tar_watcher_settings.json
|
||||
*.part
|
||||
@@ -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`
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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