photorg: initial commit
gen with claude-sonnet-4
This commit is contained in:
148
README.md
Normal file
148
README.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# PhotOrg - Организатор фотографий и медиафайлов
|
||||||
|
|
||||||
|
PhotOrg - это утилита командной строки на Go для автоматической организации фотографий и видеофайлов по датам съемки.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Рекурсивное сканирование исходной директории
|
||||||
|
- Извлечение даты съемки из EXIF и других метаданных
|
||||||
|
- Организация файлов в структуру `YYYY/YYYY-MM-DD`
|
||||||
|
- Обработка дубликатов по хеш-сумме SHA256
|
||||||
|
- Автоматическое переименование при конфликтах имен
|
||||||
|
- Перемещение файлов без метаданных в папку "Unsorted"
|
||||||
|
- Режим dry-run для предварительного просмотра изменений
|
||||||
|
- Удаление дубликатов в корзину (Linux)
|
||||||
|
|
||||||
|
## Поддерживаемые форматы
|
||||||
|
|
||||||
|
### Изображения
|
||||||
|
- JPEG (.jpg, .jpeg)
|
||||||
|
- PNG (.png)
|
||||||
|
- GIF (.gif)
|
||||||
|
- BMP (.bmp)
|
||||||
|
- TIFF (.tiff, .tif)
|
||||||
|
- RAW форматы (.cr2, .nef, .arw, .dng, .raf, .orf, .rw2)
|
||||||
|
|
||||||
|
### Видео
|
||||||
|
- MP4 (.mp4)
|
||||||
|
- AVI (.avi)
|
||||||
|
- MOV (.mov)
|
||||||
|
- MKV (.mkv)
|
||||||
|
- WMV (.wmv)
|
||||||
|
- FLV (.flv)
|
||||||
|
- WebM (.webm)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### Предварительные требования
|
||||||
|
|
||||||
|
Для работы с расширенными метаданными требуется установка ExifTool:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install exiftool
|
||||||
|
|
||||||
|
# CentOS/RHEL/Fedora
|
||||||
|
sudo yum install perl-Image-ExifTool
|
||||||
|
# или для новых версий Fedora
|
||||||
|
sudo dnf install perl-Image-ExifTool
|
||||||
|
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S perl-image-exiftool
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сборка программы
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd photorg
|
||||||
|
go mod tidy
|
||||||
|
go build -o photorg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Основной синтаксис
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./photorg -source <исходная_директория> -dest <целевая_директория> [опции]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Параметры
|
||||||
|
|
||||||
|
- `-source` - исходная директория для сканирования (обязательный)
|
||||||
|
- `-dest` - целевая директория для организации файлов (обязательный)
|
||||||
|
- `-dry-run` - режим предварительного просмотра без фактического перемещения файлов
|
||||||
|
|
||||||
|
### Примеры использования
|
||||||
|
|
||||||
|
#### Предварительный просмотр
|
||||||
|
```bash
|
||||||
|
./photorg -source /home/user/Pictures -dest /home/user/Organized -dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Организация фотографий
|
||||||
|
```bash
|
||||||
|
./photorg -source /home/user/Pictures -dest /home/user/Organized
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура результата
|
||||||
|
|
||||||
|
Программа создает следующую структуру директорий:
|
||||||
|
|
||||||
|
```
|
||||||
|
dest_directory/
|
||||||
|
├── 2023/
|
||||||
|
│ ├── 2023-01-15/
|
||||||
|
│ │ ├── IMG_001.jpg
|
||||||
|
│ │ └── IMG_002.jpg
|
||||||
|
│ └── 2023-12-25/
|
||||||
|
│ └── video.mp4
|
||||||
|
├── 2024/
|
||||||
|
│ └── 2024-03-10/
|
||||||
|
│ ├── photo.jpg
|
||||||
|
│ └── photo_1.jpg # конфликт имен
|
||||||
|
└── Unsorted/
|
||||||
|
└── file_without_date.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка конфликтов
|
||||||
|
|
||||||
|
### Идентичные файлы
|
||||||
|
Если файл с таким же именем уже существует в целевой директории и имеет идентичную хеш-сумму SHA256, исходный файл удаляется в корзину.
|
||||||
|
|
||||||
|
### Разные файлы с одинаковыми именами
|
||||||
|
Если файлы имеют разные хеш-суммы, к имени добавляется суффикс `_1`, `_2` и т.д.
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- Программа проверяет, что целевая директория не является поддиректорией исходной
|
||||||
|
- Дубликаты удаляются в корзину, а не окончательно
|
||||||
|
- Режим dry-run позволяет предварительно оценить изменения
|
||||||
|
- Все операции логируются
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Поддерживается только Linux (корзина)
|
||||||
|
- Требуется установка ExifTool для расширенной поддержки метаданных
|
||||||
|
- Программа не обрабатывает символические ссылки
|
||||||
|
|
||||||
|
## Примеры вывода
|
||||||
|
|
||||||
|
### Режим dry-run
|
||||||
|
```
|
||||||
|
[DRY RUN] Would move: /source/IMG_001.jpg -> /dest/2023/2023-01-15/IMG_001.jpg
|
||||||
|
[DRY RUN] Would move: /source/video.mp4 -> /dest/2023/2023-12-25/video.mp4
|
||||||
|
[DRY RUN] Would move: /source/no_date.png -> /dest/Unsorted/no_date.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### Обычный режим
|
||||||
|
```
|
||||||
|
Moved: /source/IMG_001.jpg -> /dest/2023/2023-01-15/IMG_001.jpg
|
||||||
|
Moved: /source/video.mp4 -> /dest/2023/2023-12-25/video.mp4
|
||||||
|
Moved: /source/no_date.png -> /dest/Unsorted/no_date.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT License
|
8
go.mod
Normal file
8
go.mod
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
module git.vakhrushev.me/av/photorg
|
||||||
|
|
||||||
|
go 1.24.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/barasher/go-exiftool v1.10.0
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||||
|
)
|
11
go.sum
Normal file
11
go.sum
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
|
||||||
|
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
|
||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
410
main.go
Normal file
410
main.go
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var config Config
|
||||||
|
|
||||||
|
flag.StringVar(&config.SourceDir, "source", "", "Source directory to scan for photos")
|
||||||
|
flag.StringVar(&config.DestDir, "dest", "", "Destination directory to organize photos")
|
||||||
|
flag.BoolVar(&config.DryRun, "dry-run", false, "Show what would be done without actually moving files")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if config.SourceDir == "" || config.DestDir == "" {
|
||||||
|
fmt.Println("Usage: photorg -source <source_dir> -dest <dest_dir> [-dry-run]")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
os.Exit(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем доступ на чтение
|
||||||
|
sourceFile, err := os.Open(sourceDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read source directory: %v", err)
|
||||||
|
}
|
||||||
|
sourceFile.Close()
|
||||||
|
|
||||||
|
// Проверяем, что 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 {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
mediaExtensions := []string{
|
||||||
|
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif",
|
||||||
|
".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm",
|
||||||
|
".cr2", ".nef", ".arw", ".dng", ".raf", ".orf", ".rw2",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mediaExt := range mediaExtensions {
|
||||||
|
if ext == mediaExt {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeFile(path string) (*FileInfo, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем хеш файла
|
||||||
|
hasher := sha256.New()
|
||||||
|
if _, err := io.Copy(hasher, file); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hash := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||||
|
|
||||||
|
// Сбрасываем указатель файла для чтения EXIF
|
||||||
|
file.Seek(0, 0)
|
||||||
|
|
||||||
|
fileInfo := &FileInfo{
|
||||||
|
Path: path,
|
||||||
|
Hash: hash,
|
||||||
|
Size: stat.Size(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пытаемся извлечь дату из метаданных
|
||||||
|
dateTaken := extractDateFromMetadata(path)
|
||||||
|
if dateTaken != nil {
|
||||||
|
fileInfo.DateTaken = dateTaken
|
||||||
|
} else {
|
||||||
|
// Если метаданные не содержат дату, используем дату модификации файла
|
||||||
|
modTime := stat.ModTime()
|
||||||
|
fileInfo.DateTaken = &modTime
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%x", hasher.Sum(nil)), 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)
|
||||||
|
}
|
Reference in New Issue
Block a user