feat: organize.py v2 — piexif, dry-run, tqdm, CR2/AVI, undo.log, --group-by, --quiet
This commit is contained in:
1 parent
ab6b9daebb
commit
a75871a994
3 files changed
+477
-133
No files matched your search
@@ -1,12 +1,15 @@
|
||||
# Raskidator-3000 🗂️
|
||||
|
||||
Сортировка фотографий по EXIF дате.
|
||||
Сортировка изображений по EXIF дате.
|
||||
|
||||
**Логика:** сканирует папку, читает EXIF (`DateTimeOriginal`), создаёт подпапки `Год/Месяц/День` и перемещает файлы туда. Файлы без EXIF даты остаются на месте.
|
||||
Сканирует папку, читает EXIF (`DateTimeOriginal`), создаёт подпапки
|
||||
`Год/Месяц/День` (или `Год/Месяц` с `--group-by month`) и перемещает файлы.
|
||||
|
||||
## Поддерживаемые форматы
|
||||
|
||||
`.jpg` `.jpeg` `.png` `.heic` `.tiff` `.tif` `.webp` `.bmp`
|
||||
`.cr2` `.cr3` `.nef` `.arw` `.dng` (Raw)
|
||||
`.avi`
|
||||
|
||||
## Установка
|
||||
|
||||
@@ -14,6 +17,10 @@
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Для Raw-форматов (`CR2`, `NEF` и т.д.) нужен `rawpy`.
|
||||
Для HEIC нужен `pillow-heif`.
|
||||
Оба ставятся только на Windows (`sys_platform == 'win32'`).
|
||||
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
@@ -22,24 +29,37 @@ python organize.py
|
||||
|
||||
# Указанная папка
|
||||
python organize.py "D:\Фото\Скопировать"
|
||||
|
||||
# Только посмотреть, что будет (ничего не перемещает)
|
||||
python organize.py --dry-run
|
||||
|
||||
# Без цвета (для пайпов)
|
||||
python organize.py --no-color
|
||||
|
||||
# Тихий режим — только итог
|
||||
python organize.py --quiet
|
||||
|
||||
# Группировать по месяцам (без дня)
|
||||
python organize.py --group-by month
|
||||
```
|
||||
|
||||
## Логи
|
||||
|
||||
После каждого запуска создаются:
|
||||
- `.raskidator-log.csv` — лог перемещений (source, destination, date)
|
||||
- `raskidator-undo.bat` — скрипт для возврата всех файлов обратно
|
||||
|
||||
## Контекстное меню Windows
|
||||
|
||||
```cmd
|
||||
# 1. Запусти install.bat (сгенерирует .reg файл с твоими путями)
|
||||
# Установить контекстное меню
|
||||
install.bat
|
||||
|
||||
# 2. Откроется install-context-menu.reg — подтверди добавление в реестр
|
||||
# Удалить контекстное меню
|
||||
uninstall-context-menu.bat
|
||||
```
|
||||
|
||||
После этого:
|
||||
После установки:
|
||||
- **ПКМ в пустом месте папки** → «Raskidator-3000» — обработает текущую папку
|
||||
- **ПКМ на папке** → «Raskidator-3000» — обработает выбранную папку
|
||||
- **Работает в библиотеках** (Pictures, Music, Videos, Documents)
|
||||
|
||||
Удалить из контекстного меню:
|
||||
```cmd
|
||||
reg delete "HKEY_CLASSES_ROOT\Directory\Background\shell\Raskidator3000" /f
|
||||
reg delete "HKEY_CLASSES_ROOT\Directory\shell\Raskidator3000" /f
|
||||
```
|
||||
+441
-119
@@ -1,224 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Raskidator-3000 — сортировка фотографий по EXIF дате.
|
||||
Raskidator-3000 — сортировка изображений по EXIF дате.
|
||||
|
||||
Сканирует указанную папку (по умолчанию текущую) на изображения,
|
||||
читает EXIF дату съёмки (DateTimeOriginal) и перемещает файлы
|
||||
в подпапки Год/Месяц/День.
|
||||
Сканирует папку, читает дату съёмки (EXIF DateTimeOriginal)
|
||||
и перемещает файлы в подпапки Год/Месяц/День.
|
||||
|
||||
Файлы без EXIF даты остаются на месте.
|
||||
|
||||
Поддерживаемые форматы: .jpg, .jpeg, .png, .heic, .tiff, .tif, .webp, .bmp
|
||||
Поддерживаемые форматы:
|
||||
JPEG, PNG, HEIC, TIFF, WebP, BMP, CR2 (Canon Raw), AVI
|
||||
|
||||
Использование:
|
||||
python organize.py [путь_к_папке]
|
||||
python organize.py --no-color [путь_к_папке]
|
||||
python organize.py [путь_к_папке] --no-color
|
||||
python organize.py [путь] [--no-color] [--dry-run] [--quiet] [--group-by month]
|
||||
python organize.py --help
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EXTENSIONS = {'.jpg', '.jpeg', '.png', '.heic', '.tiff', '.tif', '.webp', '.bmp'}
|
||||
# ── Optional imports ────────────────────────────────────────────────────────
|
||||
|
||||
# ANSI color codes
|
||||
HAS_TQDM = False
|
||||
HAS_PIEXIF = False
|
||||
HAS_RAWPY = False
|
||||
HAS_PILLOW_HEIF = False
|
||||
|
||||
try:
|
||||
from tqdm import tqdm as _tqdm
|
||||
HAS_TQDM = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import piexif
|
||||
HAS_PIEXIF = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import rawpy
|
||||
HAS_RAWPY = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from pillow_heif import open_heif
|
||||
HAS_PILLOW_HEIF = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
EXTENSIONS = {
|
||||
'.jpg', '.jpeg', '.png', '.heic', '.tiff', '.tif', '.webp', '.bmp',
|
||||
'.cr2', '.cr3', '.nef', '.arw', '.dng',
|
||||
'.avi',
|
||||
}
|
||||
|
||||
# ANSI colour codes
|
||||
_GREEN = '\033[32m'
|
||||
_YELLOW = '\033[33m'
|
||||
_RED = '\033[31m'
|
||||
_CYAN = '\033[36m'
|
||||
_BOLD = '\033[1m'
|
||||
_RESET = '\033[0m'
|
||||
|
||||
|
||||
def _colorize(text: str, color: str, no_color: bool) -> str:
|
||||
"""Wrap *text* in ANSI *color*, unless *no_color* is True."""
|
||||
def _c(text: str, colour: str, *, no_color: bool = False) -> str:
|
||||
"""Colourise *text* unless *no_color* is True."""
|
||||
if no_color:
|
||||
return text
|
||||
return f'{color}{text}{_RESET}'
|
||||
return f'{colour}{text}{_RESET}'
|
||||
|
||||
|
||||
def get_file_created_date(filepath: Path) -> datetime | None:
|
||||
"""Возвращает дату создания файла из файловой системы (Windows: st_ctime)."""
|
||||
# ── EXIF extraction (JPEG — piexif, 5-10x faster than PIL) ──────────────────
|
||||
|
||||
def _read_jpeg_exif_piexif(path: Path) -> str | None:
|
||||
"""Read DateTimeOriginal from a JPEG using piexif (no PIL needed).
|
||||
|
||||
Works on raw bytes — much faster than opening the image.
|
||||
"""
|
||||
if not HAS_PIEXIF:
|
||||
return None
|
||||
try:
|
||||
ts = filepath.stat().st_ctime
|
||||
return datetime.fromtimestamp(ts)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_exif_date(filepath: Path) -> str | None:
|
||||
"""Извлекает дату съёмки из EXIF (DateTimeOriginal)."""
|
||||
try:
|
||||
# HEIC требует pillow-heif
|
||||
if filepath.suffix.lower() == '.heic':
|
||||
try:
|
||||
from pillow_heif import open_heif
|
||||
heif_file = open_heif(str(filepath))
|
||||
exif_data = heif_file.info.get('exif', b'')
|
||||
if not exif_data:
|
||||
return None
|
||||
import piexif
|
||||
exif_dict = piexif.load(exif_data)
|
||||
if piexif.ExifIFD.DateTimeOriginal in exif_dict.get('Exif', {}):
|
||||
return exif_dict['Exif'][piexif.ExifIFD.DateTimeOriginal].decode()
|
||||
except Exception:
|
||||
return None
|
||||
else:
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS
|
||||
|
||||
img = Image.open(filepath)
|
||||
exif_data = img._getexif()
|
||||
if not exif_data:
|
||||
return None
|
||||
|
||||
tag_map = {v: k for k, v in TAGS.items()}
|
||||
datetime_original_tag = tag_map.get('DateTimeOriginal')
|
||||
if datetime_original_tag and datetime_original_tag in exif_data:
|
||||
return exif_data[datetime_original_tag]
|
||||
|
||||
# Fallback: DateTime (если DateTimeOriginal нет)
|
||||
datetime_tag = tag_map.get('DateTime')
|
||||
if datetime_tag and datetime_tag in exif_data:
|
||||
return exif_data[datetime_tag]
|
||||
|
||||
exif_dict = piexif.load(str(path))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
exif = exif_dict.get('Exif', {})
|
||||
raw = exif.get(piexif.ExifIFD.DateTimeOriginal) or exif.get(piexif.ExifIFD.DateTime)
|
||||
if raw:
|
||||
try:
|
||||
return raw.decode('utf-8')
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def parse_date(date_str: str) -> datetime | None:
|
||||
"""Парсит EXIF дату в формате 'YYYY:MM:DD HH:MM:SS'."""
|
||||
def _read_jpeg_exif_pil(path: Path) -> str | None:
|
||||
"""Fallback: PIL-based EXIF read for JPEG (slower but more compatible)."""
|
||||
try:
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
img = Image.open(path)
|
||||
raw = img._getexif()
|
||||
img.close()
|
||||
except Exception:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
tag_map = {v: k for k, v in TAGS.items()}
|
||||
for tag_name in ('DateTimeOriginal', 'DateTime'):
|
||||
tag_code = tag_map.get(tag_name)
|
||||
if tag_code and tag_code in raw:
|
||||
return raw[tag_code]
|
||||
return None
|
||||
|
||||
|
||||
def _read_heic_exif(path: Path) -> str | None:
|
||||
"""HEIC: needs pillow-heif + piexif."""
|
||||
if not HAS_PILLOW_HEIF or not HAS_PIEXIF:
|
||||
return None
|
||||
try:
|
||||
heif = open_heif(str(path))
|
||||
raw_bytes = heif.info.get('exif', b'')
|
||||
if not raw_bytes:
|
||||
return None
|
||||
exif_dict = piexif.load(raw_bytes)
|
||||
raw = exif_dict.get('Exif', {}).get(piexif.ExifIFD.DateTimeOriginal)
|
||||
if raw:
|
||||
return raw.decode('utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _read_other_exif_pil(path: Path) -> str | None:
|
||||
"""Generic PIL-based EXIF read for TIFF, WebP, BMP."""
|
||||
try:
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
img = Image.open(path)
|
||||
raw = img._getexif()
|
||||
img.close()
|
||||
except Exception:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
tag_map = {v: k for k, v in TAGS.items()}
|
||||
for tag_name in ('DateTimeOriginal', 'DateTime'):
|
||||
tag_code = tag_map.get(tag_name)
|
||||
if tag_code and tag_code in raw:
|
||||
return raw[tag_code]
|
||||
return None
|
||||
|
||||
|
||||
def _read_cr2_exif(path: Path) -> str | None:
|
||||
"""CR2 (Canon Raw): rawpy to read EXIF."""
|
||||
if not HAS_RAWPY:
|
||||
return None
|
||||
try:
|
||||
with rawpy.imread(str(path)) as raw:
|
||||
# rawpy doesn't expose parsed EXIF directly;
|
||||
# try the raw_image metadata
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: try piexif on CR2 (CR2 has a JPEG preview with EXIF)
|
||||
if HAS_PIEXIF:
|
||||
try:
|
||||
exif_dict = piexif.load(str(path))
|
||||
raw_val = exif_dict.get('Exif', {}).get(piexif.ExifIFD.DateTimeOriginal)
|
||||
if raw_val:
|
||||
return raw_val.decode('utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ── Date helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_date(date_str: str) -> datetime | None:
|
||||
"""Parse 'YYYY:MM:DD HH:MM:SS' to datetime."""
|
||||
try:
|
||||
return datetime.strptime(date_str.strip(), '%Y:%m:%d %H:%M:%S')
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def organize(folder: Path, no_color: bool = False) -> None:
|
||||
"""Сканирует папку и сортирует изображения."""
|
||||
if not folder.exists() or not folder.is_dir():
|
||||
print(f'Ошибка: папка не существует — {folder}')
|
||||
sys.exit(1)
|
||||
def _get_created_date(path: Path) -> datetime | None:
|
||||
"""Fallback: file system creation date (st_ctime on Windows)."""
|
||||
try:
|
||||
ts = path.stat().st_ctime
|
||||
return datetime.fromtimestamp(ts)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
files = [f for f in folder.iterdir() if f.is_file() and f.suffix.lower() in EXTENSIONS]
|
||||
|
||||
# ── Core ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def organize(
|
||||
folder: Path,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
no_color: bool = False,
|
||||
group_by: str = 'day',
|
||||
quiet: bool = False,
|
||||
) -> int:
|
||||
"""Scan *folder* and sort images into year/month[/day] subfolders.
|
||||
|
||||
Returns exit code (0 = ok, 1 = errors).
|
||||
"""
|
||||
if not folder.is_dir():
|
||||
print(f'Error: folder not found — {folder}')
|
||||
return 1
|
||||
|
||||
# ── Scan ────────────────────────────────────────────────────────────────
|
||||
files: list[Path] = [
|
||||
f for f in folder.iterdir()
|
||||
if f.is_file() and f.suffix.lower() in EXTENSIONS
|
||||
]
|
||||
files.sort()
|
||||
|
||||
if not files:
|
||||
print(_c(' No supported files found.', _YELLOW, no_color=no_color))
|
||||
return 0
|
||||
|
||||
total = len(files)
|
||||
|
||||
# ── Progress bar ────────────────────────────────────────────────────────
|
||||
use_tqdm = HAS_TQDM and not quiet and not dry_run
|
||||
iterator: Any = files
|
||||
if use_tqdm:
|
||||
iterator = _tqdm(files, desc='Processing', unit='file',
|
||||
bar_format='{l_bar}{bar}| {n_fmt}/{total_fmt} '
|
||||
'[{elapsed}<{remaining}, {rate_fmt}]')
|
||||
|
||||
# ── State ───────────────────────────────────────────────────────────────
|
||||
moved = 0
|
||||
skipped = 0
|
||||
no_date = 0
|
||||
no_date_count = 0
|
||||
exif_count = 0
|
||||
fs_count = 0
|
||||
year_counts: Counter[int] = Counter()
|
||||
move_log: list[dict[str, str]] = []
|
||||
|
||||
for filepath in files:
|
||||
dt = None
|
||||
date_source = None
|
||||
start_time = time.time()
|
||||
|
||||
for filepath in iterator:
|
||||
dt: datetime | None = None
|
||||
from_exif = False
|
||||
date_source_label: str | None = None
|
||||
|
||||
# 1. EXIF DateTimeOriginal
|
||||
date_str = get_exif_date(filepath)
|
||||
if date_str:
|
||||
dt = parse_date(date_str)
|
||||
if dt:
|
||||
from_exif = True
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
# 2. Fallback: дата создания файла (Windows: st_ctime)
|
||||
# ── EXIF tier 1: JPEG — piexif (fastest) ────────────────────────
|
||||
if ext in ('.jpg', '.jpeg'):
|
||||
date_str = _read_jpeg_exif_piexif(filepath)
|
||||
if date_str:
|
||||
dt = _parse_date(date_str)
|
||||
if dt:
|
||||
from_exif = True
|
||||
|
||||
# ── EXIF tier 2: HEIC (pillow-heif + piexif) ────────────────────
|
||||
if dt is None and ext == '.heic':
|
||||
date_str = _read_heic_exif(filepath)
|
||||
if date_str:
|
||||
dt = _parse_date(date_str)
|
||||
if dt:
|
||||
from_exif = True
|
||||
|
||||
# ── EXIF tier 3: CR2/raw (piexif on JPEG preview) ──────────────
|
||||
if dt is None and ext in ('.cr2', '.cr3'):
|
||||
date_str = _read_cr2_exif(filepath)
|
||||
if date_str:
|
||||
dt = _parse_date(date_str)
|
||||
if dt:
|
||||
from_exif = True
|
||||
|
||||
# ── EXIF tier 4: JPEG fallback (PIL) ───────────────────────────
|
||||
if dt is None and ext in ('.jpg', '.jpeg'):
|
||||
date_str = _read_jpeg_exif_pil(filepath)
|
||||
if date_str:
|
||||
dt = _parse_date(date_str)
|
||||
if dt:
|
||||
from_exif = True
|
||||
|
||||
# ── EXIF tier 5: other formats (TIFF, WebP, BMP, PNG) ──────────
|
||||
if dt is None and ext not in ('.avi',):
|
||||
date_str = _read_other_exif_pil(filepath)
|
||||
if date_str:
|
||||
dt = _parse_date(date_str)
|
||||
if dt:
|
||||
from_exif = True
|
||||
|
||||
# ── Fallback: file creation date (always available) ────────────
|
||||
if dt is None:
|
||||
dt = get_file_created_date(filepath)
|
||||
dt = _get_created_date(filepath)
|
||||
if dt:
|
||||
date_source = 'дата создания файла'
|
||||
date_source_label = 'Created Date'
|
||||
|
||||
# ── No date → skip ─────────────────────────────────────────────
|
||||
if dt is None:
|
||||
msg = (
|
||||
f' [{_c("—", _RED, no_color=no_color)}] '
|
||||
f'{filepath.name} — no date, skipped'
|
||||
)
|
||||
if not quiet:
|
||||
print(msg)
|
||||
no_date_count += 1
|
||||
skipped += 1
|
||||
no_date += 1
|
||||
msg = f' [—] {filepath.name} — нет даты (EXIF + CreatedDate), оставлен на месте'
|
||||
print(_colorize(msg, _RED, no_color))
|
||||
continue
|
||||
|
||||
# Track source and year for final statistics
|
||||
# ── Track stats ────────────────────────────────────────────────
|
||||
if from_exif:
|
||||
exif_count += 1
|
||||
else:
|
||||
fs_count += 1
|
||||
year_counts[dt.year] += 1
|
||||
|
||||
dest_dir = folder / str(dt.year) / f'{dt.month:02d}' / f'{dt.day:02d}'
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
# ── Build destination ──────────────────────────────────────────
|
||||
if group_by == 'month':
|
||||
dest = folder / str(dt.year) / f'{dt.month:02d}'
|
||||
else:
|
||||
dest = folder / str(dt.year) / f'{dt.month:02d}' / f'{dt.day:02d}'
|
||||
|
||||
dest_path = dest_dir / filepath.name
|
||||
dest_file = dest / filepath.name
|
||||
|
||||
# Если файл с таким именем уже есть — добавляем суффикс
|
||||
if dest_path.exists():
|
||||
# Handle filename collisions
|
||||
if dest_file.exists():
|
||||
stem = filepath.stem
|
||||
suffix = filepath.suffix
|
||||
counter = 1
|
||||
n = 1
|
||||
while True:
|
||||
dest_path = dest_dir / f'{stem}_{counter}{suffix}'
|
||||
if not dest_path.exists():
|
||||
dest_file = dest / f'{stem}_{n}{suffix}'
|
||||
if not dest_file.exists():
|
||||
break
|
||||
counter += 1
|
||||
n += 1
|
||||
|
||||
# ── Execute move or dry-run ────────────────────────────────────
|
||||
label = f'{dt.year}/{dt.month:02d}/{dt.day:02d}' if group_by == 'day' \
|
||||
else f'{dt.year}/{dt.month:02d}'
|
||||
|
||||
if dry_run:
|
||||
src_tag = f' ({date_source_label})' if date_source_label else ''
|
||||
move_log.append({
|
||||
'source': str(filepath),
|
||||
'destination': str(dest_file),
|
||||
'date': str(dt),
|
||||
'date_source': 'EXIF' if from_exif else (date_source_label or '?'),
|
||||
})
|
||||
if not quiet and not use_tqdm:
|
||||
marker = _c('D', _CYAN, no_color=no_color)
|
||||
icon = _c('✓', _GREEN, no_color=no_color) if from_exif \
|
||||
else _c('✓', _YELLOW, no_color=no_color)
|
||||
src_tag = f' ({date_source_label})' if date_source_label else ''
|
||||
msg = f' [{marker}] {filepath.name} → {label}{src_tag}'
|
||||
print(msg)
|
||||
moved += 1
|
||||
continue
|
||||
|
||||
# Real move
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.move(str(filepath), str(dest_file))
|
||||
except OSError as e:
|
||||
msg = (
|
||||
f' [{_c("✗", _RED, no_color=no_color)}] '
|
||||
f'{filepath.name} — move failed: {e}'
|
||||
)
|
||||
if not quiet:
|
||||
print(msg)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
move_log.append({
|
||||
'source': str(filepath),
|
||||
'destination': str(dest_file),
|
||||
'date': str(dt),
|
||||
'date_source': 'EXIF' if from_exif else (date_source_label or '?'),
|
||||
})
|
||||
|
||||
if not quiet and not use_tqdm:
|
||||
src_tag = f' ({date_source_label})' if date_source_label else ''
|
||||
colour = _GREEN if from_exif else _YELLOW
|
||||
msg = (
|
||||
f' [{_c("✓", colour, no_color=no_color)}] '
|
||||
f'{filepath.name} → {label}{src_tag}'
|
||||
)
|
||||
print(msg)
|
||||
|
||||
shutil.move(str(filepath), str(dest_path))
|
||||
moved += 1
|
||||
src = f' ({date_source})' if date_source else ''
|
||||
msg = f' [✓] {filepath.name} → {dt.year}/{dt.month:02d}/{dt.day:02d}/{src}'
|
||||
|
||||
if from_exif:
|
||||
# Green — valid EXIF date
|
||||
print(_colorize(msg, _GREEN, no_color))
|
||||
else:
|
||||
# Yellow — fallback to file system creation date
|
||||
print(_colorize(msg, _YELLOW, no_color))
|
||||
if not dry_run and not quiet and use_tqdm and isinstance(iterator, _tqdm):
|
||||
iterator.set_postfix(moved=moved, skipped=skipped)
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────
|
||||
elapsed = time.time() - start_time
|
||||
print()
|
||||
print('=== Year Summary ===')
|
||||
print(_c('=== Year Summary ===', _BOLD, no_color=no_color))
|
||||
if year_counts:
|
||||
for year in sorted(year_counts):
|
||||
print(f' {year}: {year_counts[year]} entries')
|
||||
print(f' {year}: {year_counts[year]} files')
|
||||
else:
|
||||
print(' (no entries with a determinable year)')
|
||||
print()
|
||||
print(f' Total with EXIF date: {exif_count}')
|
||||
print(f' Total with FS date: {fs_count}')
|
||||
print(f' Total skipped: {skipped}')
|
||||
print(f' Total processed: {moved + skipped}')
|
||||
print()
|
||||
print(f'Готово! Перемещено: {moved}, пропущено: {skipped} (без даты: {no_date})')
|
||||
|
||||
mode_label = 'DRY-RUN (no files moved)' if dry_run else ''
|
||||
if mode_label:
|
||||
print(_c(mode_label, _CYAN, no_color=no_color))
|
||||
|
||||
print(f' EXIF date: {exif_count}')
|
||||
print(f' Created Date: {fs_count}')
|
||||
print(f' Skipped (no date): {skipped}')
|
||||
print(f' Total processed: {moved + skipped}')
|
||||
print(f' Time: {elapsed:.1f}s')
|
||||
print()
|
||||
|
||||
if dry_run:
|
||||
print(_c(f'Dry-run: {moved} would be moved, {skipped} skipped (no date).',
|
||||
_CYAN, no_color=no_color))
|
||||
else:
|
||||
print(f'Done! Moved: {moved}, skipped: {skipped} (no date: {no_date_count})')
|
||||
|
||||
# ── Write move log ──────────────────────────────────────────────────
|
||||
if move_log and not dry_run:
|
||||
log_path = folder / '.raskidator-log.csv'
|
||||
undo_path = folder / 'raskidator-undo.bat'
|
||||
_write_logs(move_log, log_path, undo_path)
|
||||
print()
|
||||
print(_c(f' Log: {log_path}', _CYAN, no_color=no_color))
|
||||
print(_c(f' Undo: {undo_path} (run to restore files)',
|
||||
_CYAN, no_color=no_color))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
# ── Logging helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _write_logs(entries: list[dict[str, str]],
|
||||
log_path: Path,
|
||||
undo_path: Path) -> None:
|
||||
"""Write CSV move log + undo.bat."""
|
||||
fieldnames = ['source', 'destination', 'date', 'date_source']
|
||||
with log_path.open('w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(entries)
|
||||
|
||||
# Undo .bat — restores files in reverse order of columns
|
||||
with undo_path.open('w', encoding='utf-8') as f:
|
||||
f.write('@echo off\r\n')
|
||||
f.write('title Raskidator-3000 — Undo move\r\n')
|
||||
f.write('echo Restoring files...\r\n')
|
||||
f.write('echo.\r\n')
|
||||
# Reverse: move destination → source
|
||||
for entry in reversed(entries):
|
||||
src = entry['source']
|
||||
dst = entry['destination']
|
||||
f.write(f'move "{dst}" "{src}" >nul 2>&1\r\n')
|
||||
if not f.write: # impossible but keep mypy happy
|
||||
pass
|
||||
f.write('echo.\r\n')
|
||||
f.write(f'echo Restored {len(entries)} files.\r\n')
|
||||
f.write('pause\r\n')
|
||||
|
||||
|
||||
# ── CLI entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog='organize.py',
|
||||
description='Raskidator-3000 — сортировка фотографий по EXIF дате.',
|
||||
description='Raskidator-3000 — sort images by EXIF date.',
|
||||
add_help=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
'path',
|
||||
nargs='?',
|
||||
default=None,
|
||||
help='Путь к папке с фотографиями (по умолчанию текущая папка)',
|
||||
help='Folder to scan (default: current directory)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-color',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='Отключить цветной вывод ANSI (для совместимости с пайпами)',
|
||||
help='Disable ANSI color output (for pipes)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='Show what would be moved without actually moving',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--quiet', '-q',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='Suppress per-file output (shows summary only)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--group-by',
|
||||
choices=('day', 'month'),
|
||||
default='day',
|
||||
help='Output folder granularity: year/month/day (default) or year/month',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
folder = Path(args.path) if args.path else Path.cwd()
|
||||
no_color = args.no_color
|
||||
|
||||
print(f'Raskidator-3000 — сортировка фотографий')
|
||||
print(f'Папка: {folder.resolve()}')
|
||||
print(_c('Raskidator-3000', _BOLD, no_color=args.no_color))
|
||||
print(f' Folder: {folder.resolve()}')
|
||||
if args.dry_run:
|
||||
print(_c(' Mode: DRY-RUN', _CYAN, no_color=args.no_color))
|
||||
print()
|
||||
organize(folder, no_color=no_color)
|
||||
|
||||
rc = organize(
|
||||
folder,
|
||||
dry_run=args.dry_run,
|
||||
no_color=args.no_color,
|
||||
group_by=args.group_by,
|
||||
quiet=args.quiet,
|
||||
)
|
||||
sys.exit(rc)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+5
-3
@@ -1,3 +1,5 @@
|
||||
Pillow>=10.0.0
|
||||
piexif>=1.1.3
|
||||
pillow-heif>=0.16.0
|
||||
Pillow
|
||||
piexif>=1.1.2
|
||||
tqdm
|
||||
rawpy; sys_platform == 'win32'
|
||||
pillow-heif; sys_platform == 'win32'
|
||||
Reference in new issue
Block a user