diff --git a/organize.py b/organize.py index 3c619fe..40d50b3 100644 --- a/organize.py +++ b/organize.py @@ -12,16 +12,33 @@ Raskidator-3000 — сортировка фотографий по EXIF дате Использование: python organize.py [путь_к_папке] + python organize.py --no-color [путь_к_папке] + python organize.py [путь_к_папке] --no-color """ +import argparse import os import sys import shutil +from collections import Counter from datetime import datetime from pathlib import Path EXTENSIONS = {'.jpg', '.jpeg', '.png', '.heic', '.tiff', '.tif', '.webp', '.bmp'} +# ANSI color codes +_GREEN = '\033[32m' +_YELLOW = '\033[33m' +_RED = '\033[31m' +_RESET = '\033[0m' + + +def _colorize(text: str, color: str, no_color: bool) -> str: + """Wrap *text* in ANSI *color*, unless *no_color* is True.""" + if no_color: + return text + return f'{color}{text}{_RESET}' + def get_file_created_date(filepath: Path) -> datetime | None: """Возвращает дату создания файла из файловой системы (Windows: st_ctime).""" @@ -82,7 +99,7 @@ def parse_date(date_str: str) -> datetime | None: return None -def organize(folder: Path) -> None: +def organize(folder: Path, no_color: bool = False) -> None: """Сканирует папку и сортирует изображения.""" if not folder.exists() or not folder.is_dir(): print(f'Ошибка: папка не существует — {folder}') @@ -94,15 +111,21 @@ def organize(folder: Path) -> None: moved = 0 skipped = 0 no_date = 0 + exif_count = 0 + fs_count = 0 + year_counts: Counter[int] = Counter() for filepath in files: dt = None date_source = None + from_exif = False # 1. EXIF DateTimeOriginal date_str = get_exif_date(filepath) if date_str: dt = parse_date(date_str) + if dt: + from_exif = True # 2. Fallback: дата создания файла (Windows: st_ctime) if dt is None: @@ -113,9 +136,17 @@ def organize(folder: Path) -> None: if dt is None: skipped += 1 no_date += 1 - print(f' [—] {filepath.name} — нет даты (EXIF + CreatedDate), оставлен на месте') + msg = f' [—] {filepath.name} — нет даты (EXIF + CreatedDate), оставлен на месте' + print(_colorize(msg, _RED, no_color)) continue + # Track source and year for final statistics + 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) @@ -135,18 +166,59 @@ def organize(folder: Path) -> None: shutil.move(str(filepath), str(dest_path)) moved += 1 src = f' ({date_source})' if date_source else '' - print(f' [✓] {filepath.name} → {dt.year}/{dt.month:02d}/{dt.day:02d}/{src}') + 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)) + + print() + print('=== Year Summary ===') + if year_counts: + for year in sorted(year_counts): + print(f' {year}: {year_counts[year]} entries') + 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})') -def main(): - folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd() +def main() -> None: + parser = argparse.ArgumentParser( + prog='organize.py', + description='Raskidator-3000 — сортировка фотографий по EXIF дате.', + add_help=True, + ) + parser.add_argument( + 'path', + nargs='?', + default=None, + help='Путь к папке с фотографиями (по умолчанию текущая папка)', + ) + parser.add_argument( + '--no-color', + action='store_true', + default=False, + help='Отключить цветной вывод ANSI (для совместимости с пайпами)', + ) + + 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() - organize(folder) + organize(folder, no_color=no_color) if __name__ == '__main__': diff --git a/test_stats.py b/test_stats.py new file mode 100644 index 0000000..eb1df64 --- /dev/null +++ b/test_stats.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Verify year-based final statistics aggregation in organize.py. + +Creates test images with different EXIF years (and some without EXIF), +runs the organize function, and checks the output summary. +""" +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path +from collections import Counter + +# Make sure we can import organize +HERE = Path(__file__).parent.absolute() +if str(HERE) not in sys.path: + sys.path.insert(0, str(HERE)) + +from PIL import Image +from PIL.ExifTags import Base as ExifBase + +from organize import organize + + +def _make_image(tmpdir: Path, name: str, year: int | None = None) -> Path: + """Create a test image. If *year* is given, embed EXIF DateTimeOriginal.""" + img = Image.new("RGB", (16, 16), color="red") + path = tmpdir / name + + if year is not None: + exif_data = img.getexif() + exif_data[ExifBase.DateTimeOriginal] = f"{year:04d}:06:15 12:00:00" + img.save(path, exif=exif_data.tobytes()) + else: + img.save(path) # no EXIF + + img.close() + return path + + +def test_stats_summary_format() -> None: + """Check that the summary section is printed after per-line output.""" + import io + + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + + # Create images with known years + _make_image(tmp, "img_2023.jpg", year=2023) + _make_image(tmp, "img_2024a.jpg", year=2024) + _make_image(tmp, "img_2024b.jpg", year=2024) + _make_image(tmp, "img_2025.jpg", year=2025) + _make_image(tmp, "no_exif.jpg", year=None) # skipped in our test + + # We just check it doesn't crash and returns correct exit + # We'll capture stdout to verify the summary + old_stdout = sys.stdout + captured = io.StringIO() + sys.stdout = captured + try: + organize(tmp, no_color=True) + finally: + sys.stdout = old_stdout + + output = captured.getvalue() + + # Verify the summary section exists + assert "=== Year Summary ===" in output, "Year summary header missing" + + # Verify per-year counts + assert "2023: 1 entries" in output, "Year 2023 count wrong" + assert "2024: 2 entries" in output, "Year 2024 count wrong" + assert "2025: 1 entries" in output, "Year 2025 count wrong" + + # Verify category counts + assert "Total with EXIF date:" in output + assert "Total with FS date:" in output + assert "Total skipped:" in output + + # Summary appears after per-line output (no interleaving check via text position) + + print("test_stats_summary_format: PASS") + + +def test_stats_counts_match() -> None: + """Verify that category totals add up correctly.""" + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + + # 3 with EXIF, 1 without (uses FS date via mtime), 1 corrupt (skipped) + _make_image(tmp, "a_2022.jpg", year=2022) + _make_image(tmp, "b_2023.jpg", year=2023) + _make_image(tmp, "c_2024.jpg", year=2024) + + # Image without EXIF — falls back to FS date + img = Image.new("RGB", (8, 8), color="blue") + p = tmp / "no_exif.jpg" + img.save(p) + img.close() + + # Corrupt file with valid extension — skipped + (tmp / "corrupt.jpg").write_text("not an image") + + old_stdout = sys.stdout + captured = io.StringIO() + sys.stdout = captured + try: + organize(tmp, no_color=True) + finally: + sys.stdout = old_stdout + + output = captured.getvalue() + + # Verify counts + assert "Total with EXIF date: 3" in output + assert "Total with FS date: 1" in output + assert "Total skipped: 1" in output + assert "Total processed: 5" in output + + print("test_stats_counts_match: PASS") + + +if __name__ == "__main__": + import io + test_stats_summary_format() + test_stats_counts_match() + print("All stats tests passed!")