package main import ( "bufio" "errors" "flag" "log" "os" "os/signal" "path" "strconv" "strings" "syscall" "time" "github.com/fsnotify/fsnotify" ) var sourcePath string var targetPath string func getSeenPath() string { cacheDir, err := os.UserCacheDir() if err != nil { log.Fatal(err, "No cache directory available") } return path.Join(cacheDir, "photosorter", "seen") } func prepareStateDir() { parent := path.Dir(getSeenPath()) log.Println("Storing seen cache in:", parent) os.MkdirAll(parent, 0700) } func getSeen() map[string]bool { seenFiles := make(map[string]bool) file, err := os.Open(getSeenPath()) if err != nil { return seenFiles } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { seenFiles[scanner.Text()] = true } return seenFiles } func setSeen(seenFiles map[string]bool) { file, err := os.Create(getSeenPath()) if err != nil { return } defer file.Close() w := bufio.NewWriter(file) for key := range seenFiles { w.WriteString(key) w.WriteString("\n") } w.Flush() } func processFile(name string, modified time.Time) { sourceFile := path.Join(sourcePath, name) targetDir := path.Join(targetPath, strconv.Itoa(modified.Year())) targetFile := path.Join(targetDir, name) if _, err := os.Stat(targetFile); errors.Is(err, os.ErrNotExist) { log.Println("Copying src:", sourceFile, "target:", targetFile) r, err := os.Open(sourceFile) if err != nil { return } defer r.Close() os.MkdirAll(targetDir, 0700) w, err := os.Create(targetFile) if err != nil { return } defer w.Close() _, err = w.ReadFrom(r) if err != nil { // Try to clean up for next time os.Remove(targetFile) } } else { log.Println("Skipping because it exists, src:", sourceFile, "target:", targetFile) } } func doSort() { log.Println("Starting sort...") didProcess := false seenFiles := getSeen() newSeenFiles := make(map[string]bool) files, err := os.ReadDir(sourcePath) if err != nil { log.Fatal(err) } for _, file := range files { if strings.HasPrefix(file.Name(), ".") { continue } if !file.Type().IsRegular() { continue } info, err := file.Info() if err != nil { continue } newSeenFiles[file.Name()] = true if seenFiles[file.Name()] { continue } processFile(file.Name(), info.ModTime()) didProcess = true } setSeen(newSeenFiles) if didProcess { log.Println("Sort complete") } else { log.Println("Sort complete (no changes)") } } func sortWorker(changeTimes chan time.Time) { // Handle signals cleanly so we avoid stopping mid-execution stopping := make(chan os.Signal) signal.Notify(stopping, syscall.SIGTERM, syscall.SIGHUP) // Initial sort to catch any changes before startup lastRun := time.Now() doSort() for { select { case <-stopping: log.Println("Stopping due to signal") os.Exit(0) case t := <-changeTimes: if t.After(lastRun) { log.Println("Source directory did change") lastRun = time.Now() doSort() } } } } func watcher(changeTimes chan time.Time) { w, err := fsnotify.NewWatcher() if err != nil { log.Fatal(err) } defer w.Close() w.Add(sourcePath) for range w.Events { changeTimes <- time.Now() } } func main() { const req = "REQUIRED" flag.StringVar(&sourcePath, "s", req, "source directory where photos are uploaded") flag.StringVar(&targetPath, "t", req, "target directory in which year directories are created") flag.Parse() if sourcePath == req || targetPath == req { log.Println("Source and Target paths must both be provided") os.Exit(1) } prepareStateDir() changeTimes := make(chan time.Time, 1024) go sortWorker(changeTimes) watcher(changeTimes) }