package main import ( "crypto/sha256" "flag" "fmt" "io" "log" "os" "path/filepath" "slices" "strings" "time" "github.com/barasher/go-exiftool" "github.com/rwcarlsen/goexif/exif" ) type Config struct { SourceDir string DestDir string DryRun bool } type FileInfo struct { Path string DateTaken *time.Time Hash string } func main() { var config Config flag.BoolVar(&config.DryRun, "dry-run", false, "Show what would be done without actually moving files") flag.Parse() // Получаем позиционные аргументы args := flag.Args() if len(args) != 2 { fmt.Println("Usage: photorg [-dry-run]") fmt.Println("\nArguments:") fmt.Println(" source_dir Source directory to scan for photos") fmt.Println(" dest_dir Destination directory to organize photos") fmt.Println("\nOptions:") flag.PrintDefaults() os.Exit(1) } config.SourceDir = args[0] config.DestDir = args[1] if err := validateDirectories(config.SourceDir, config.DestDir); err != nil { log.Fatal(err) } if err := organizePhotos(config); err != nil { log.Fatal(err) } } func validateDirectories(sourceDir, destDir string) error { // Проверяем, что sourceDir существует и доступен для чтения sourceStat, err := os.Stat(sourceDir) if err != nil { return fmt.Errorf("source directory error: %v", err) } if !sourceStat.IsDir() { return fmt.Errorf("source path is not a directory: %s", sourceDir) } // Проверяем, что destDir не пустая if strings.TrimSpace(destDir) == "" { return fmt.Errorf("destination directory cannot be empty") } // Получаем абсолютные пути для сравнения absSource, err := filepath.Abs(sourceDir) if err != nil { return fmt.Errorf("cannot get absolute path for source: %v", err) } absDest, err := filepath.Abs(destDir) if err != nil { return fmt.Errorf("cannot get absolute path for destination: %v", err) } // Проверяем, что destDir не является поддиректорией sourceDir if strings.HasPrefix(absDest, absSource+string(filepath.Separator)) { return fmt.Errorf("destination directory cannot be a subdirectory of source directory") } return nil } func organizePhotos(config Config) error { return filepath.Walk(config.SourceDir, func(path string, info os.FileInfo, err error) error { if err != nil { log.Printf("Error accessing %s: %v", path, err) return nil } if info.IsDir() { return nil } // Проверяем, является ли файл изображением или видео if !isMediaFile(path) { return nil } fileInfo, err := analyzeFile(path) if err != nil { log.Printf("Error analyzing file %s: %v", path, err) return nil } destPath := generateDestinationPath(config.DestDir, fileInfo) if config.DryRun { fmt.Printf("[DRY RUN] Would move: %s -> %s\n", path, destPath) return nil } if err := moveFile(fileInfo, destPath); err != nil { log.Printf("Error moving file %s: %v", path, err) } else { fmt.Printf("Moved: %s -> %s\n", path, destPath) } return nil }) } func isMediaFile(path string) bool { mediaExtensions := []string{ ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".heif", ".heifs", ".heic", ".heics", ".avci", ".avcs", ".hif", ".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm", ".cr2", ".nef", ".arw", ".dng", ".raf", ".orf", ".rw2", } ext := strings.ToLower(filepath.Ext(path)) return slices.Contains(mediaExtensions, ext) } func analyzeFile(path string) (*FileInfo, error) { hash, err := calculateFileHash(path) if err == nil { return nil, err } // Пытаемся извлечь дату из метаданных dateTaken := extractDateFromMetadata(path) fileInfo := &FileInfo{ Path: path, Hash: hash, DateTaken: dateTaken, } return fileInfo, nil } func extractDateFromMetadata(path string) *time.Time { // Сначала пытаемся использовать простой EXIF парсер if date := extractDateFromEXIF(path); date != nil { return date } // Если не получилось, используем exiftool для более широкой поддержки форматов if date := extractDateFromExifTool(path); date != nil { return date } return nil } func extractDateFromEXIF(path string) *time.Time { file, err := os.Open(path) if err != nil { return nil } defer file.Close() exifData, err := exif.Decode(file) if err != nil { return nil } // Пытаемся получить дату съемки из различных EXIF тегов dateFields := []exif.FieldName{ exif.DateTimeOriginal, exif.DateTime, exif.DateTimeDigitized, } for _, field := range dateFields { tag, err := exifData.Get(field) if err != nil { continue } dateStr, err := tag.StringVal() if err != nil { continue } // Парсим дату в формате EXIF: "2006:01:02 15:04:05" date, err := time.Parse("2006:01:02 15:04:05", dateStr) if err != nil { continue } return &date } return nil } func extractDateFromExifTool(path string) *time.Time { et, err := exiftool.NewExiftool() if err != nil { return nil } defer et.Close() fileInfos := et.ExtractMetadata(path) if len(fileInfos) == 0 { return nil } fileInfo := fileInfos[0] if fileInfo.Err != nil { return nil } // Пытаемся найти дату в различных полях метаданных dateFields := []string{ "DateTimeOriginal", "CreateDate", "DateTime", "DateTimeDigitized", "MediaCreateDate", "TrackCreateDate", } for _, field := range dateFields { if dateValue, ok := fileInfo.Fields[field]; ok { // Приводим к строке dateStr, ok := dateValue.(string) if !ok { continue } // Пытаемся парсить различные форматы дат formats := []string{ "2006:01:02 15:04:05", "2006-01-02 15:04:05", "2006:01:02 15:04:05-07:00", "2006-01-02T15:04:05Z", "2006-01-02T15:04:05-07:00", } for _, format := range formats { if date, err := time.Parse(format, dateStr); err == nil { return &date } } } } return nil } func generateDestinationPath(destDir string, fileInfo *FileInfo) string { var targetDir string if fileInfo.DateTaken != nil { year := fileInfo.DateTaken.Format("2006") date := fileInfo.DateTaken.Format("2006-01-02") targetDir = filepath.Join(destDir, year, date) } else { targetDir = filepath.Join(destDir, "Unsorted") } fileName := filepath.Base(fileInfo.Path) targetPath := filepath.Join(targetDir, fileName) // Проверяем, нужно ли добавить суффикс для избежания конфликтов return resolveFileConflict(targetPath, fileInfo.Hash) } func resolveFileConflict(targetPath, newFileHash string) string { if _, err := os.Stat(targetPath); os.IsNotExist(err) { return targetPath } // Файл существует, проверяем хеш existingHash, err := calculateFileHash(targetPath) if err != nil { log.Printf("Error calculating hash for existing file %s: %v", targetPath, err) return generateUniqueFileName(targetPath) } if existingHash == newFileHash { // Файлы идентичны, можно удалить исходный return targetPath } // Файлы разные, нужно создать уникальное имя return generateUniqueFileName(targetPath) } func generateUniqueFileName(basePath string) string { dir := filepath.Dir(basePath) ext := filepath.Ext(basePath) nameWithoutExt := strings.TrimSuffix(filepath.Base(basePath), ext) for i := 1; ; i++ { newName := fmt.Sprintf("%s_%d%s", nameWithoutExt, i, ext) newPath := filepath.Join(dir, newName) if _, err := os.Stat(newPath); os.IsNotExist(err) { return newPath } } } func calculateFileHash(path string) (string, error) { file, err := os.Open(path) if err != nil { return "", err } defer file.Close() hasher := sha256.New() if _, err := io.Copy(hasher, file); err != nil { return "", err } hash := fmt.Sprintf("%x", hasher.Sum(nil)) return hash, nil } func moveFile(fileInfo *FileInfo, destPath string) error { // Создаем директорию назначения, если она не существует destDir := filepath.Dir(destPath) if err := os.MkdirAll(destDir, 0755); err != nil { return fmt.Errorf("failed to create destination directory: %v", err) } // Проверяем, нужно ли удалить исходный файл (если файлы идентичны) if _, err := os.Stat(destPath); err == nil { existingHash, err := calculateFileHash(destPath) if err == nil && existingHash == fileInfo.Hash { // Файлы идентичны, удаляем исходный в корзину return moveToTrash(fileInfo.Path) } } // Перемещаем файл return os.Rename(fileInfo.Path, destPath) } func moveToTrash(path string) error { // Простая реализация перемещения в корзину для Linux homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("cannot get home directory: %v", err) } trashDir := filepath.Join(homeDir, ".local/share/Trash/files") if err := os.MkdirAll(trashDir, 0755); err != nil { return fmt.Errorf("cannot create trash directory: %v", err) } fileName := filepath.Base(path) trashPath := filepath.Join(trashDir, fileName) // Если файл с таким именем уже есть в корзине, добавляем суффикс trashPath = generateUniqueFileName(trashPath) return os.Rename(path, trashPath) }