diff --git a/Makefile b/Makefile index 526d46f..c15abcd 100644 --- a/Makefile +++ b/Makefile @@ -13,4 +13,4 @@ typecheck: test: pytest -check: lint test format typecheck \ No newline at end of file +check: format lint typecheck test \ No newline at end of file diff --git a/README.md b/README.md index 2466248..64d342f 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,17 @@ photo-tools raws photo-tools clean-raws ``` +### Keep RAW files for 5-star JPGs (`keep-5star-raws`) + +- Move RAW files to raws-5-star/ if a matching JPG has a 5-star rating +- Ratings are read from the JPG files, not the RAW files +- Matching is based on filename prefix (e.g. abcd.RAF matches abcd.jpg or abcd_edit.jpg) +- Files are moved (not copied), making the operation reversible + +```shell +photo-tools keep-5star-raws +``` + ### Optimise images (`optimise`) - Resize images to a maximum width of `2500px` diff --git a/pyproject.toml b/pyproject.toml index 11f33a9..7dfcea3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "photo-tools-cli" -version = "0.1.1" +version = "0.2.0" description = "Python CLI tools for photography workflows" readme = "README.md" requires-python = ">=3.13" diff --git a/src/photo_tools/cli.py b/src/photo_tools/cli.py index 0c97f4a..0f0ea2c 100644 --- a/src/photo_tools/cli.py +++ b/src/photo_tools/cli.py @@ -4,6 +4,7 @@ from photo_tools.cli_support.cli_errors import handle_cli_errors from photo_tools.cli_support.cli_reporter import make_reporter from photo_tools.commands.clean_unpaired_raws import clean_unpaired_raws +from photo_tools.commands.keep_five_star_raws import keep_five_star_raws from photo_tools.commands.optimise import optimise from photo_tools.commands.organise_by_date import organise_by_date from photo_tools.commands.separate_raws import separate_raws @@ -130,6 +131,40 @@ def clean_unpaired_raws_cmd( ) +@app.command( + "keep-5star-raws", + help="Move RAW files with matching 5-star JPGs to 'raws-5-star'.", +) +@handle_cli_errors +def keep_five_star_raws_cmd( + raw_dir: str = typer.Argument( + ..., + help="Directory containing RAW files.", + ), + jpg_dir: str = typer.Argument( + ..., + help="Directory containing rated JPG files used for matching.", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Preview changes without moving files.", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show per-file output.", + ), +) -> None: + keep_five_star_raws( + raw_dir=raw_dir, + jpg_dir=jpg_dir, + report=make_reporter(verbose), + dry_run=dry_run, + ) + + @app.command( "optimise", help="Resize JPG images to max 2500px width and compress to ≤500KB using quality " diff --git a/src/photo_tools/commands/clean_unpaired_raws.py b/src/photo_tools/commands/clean_unpaired_raws.py index 54e3190..cc9cc1c 100644 --- a/src/photo_tools/commands/clean_unpaired_raws.py +++ b/src/photo_tools/commands/clean_unpaired_raws.py @@ -1,9 +1,8 @@ import logging -import shutil from collections.abc import Callable from pathlib import Path -from photo_tools.core.validation import validate_input_dir +from photo_tools.image.raw_utils import get_matching_jpgs, move_raws_by_rule logger = logging.getLogger(__name__) @@ -11,6 +10,14 @@ JPG_EXTENSIONS = {".jpg", ".jpeg"} Reporter = Callable[[str, str], None] +RawMatcher = Callable[[Path, list[Path]], bool] + + +def has_matching_jpg( + raw_file: Path, + jpg_files: list[Path], +) -> bool: + return bool(get_matching_jpgs(raw_file, jpg_files)) def clean_unpaired_raws( @@ -19,71 +26,17 @@ def clean_unpaired_raws( report: Reporter, dry_run: bool = False, ) -> None: - raw_path = Path(raw_dir) - jpg_path = Path(jpg_dir) - trash_dir = raw_path / "raws-to-delete" - - validate_input_dir(raw_path) - validate_input_dir(jpg_path) - - moved_count = 0 - dry_run_count = 0 - skipped_existing_count = 0 - - jpg_files = [ - f - for f in jpg_path.iterdir() - if f.is_file() and f.suffix.lower() in JPG_EXTENSIONS - ] - - for raw_file in raw_path.iterdir(): - if not raw_file.is_file(): - continue - - if raw_file.suffix.lower() not in RAW_EXTENSIONS: - continue - - raw_stem = raw_file.stem.lower() - has_match = any(jpg.name.lower().startswith(raw_stem) for jpg in jpg_files) - - if has_match: - logger.debug("Keeping %s (matched JPG)", raw_file.name) - continue - - target_file = trash_dir / raw_file.name - - if target_file.exists(): - skipped_existing_count += 1 - report( - "warning", - f"Skipping {raw_file.name}: already in raws-to-delete", + move_raws_by_rule( + raw_dir=raw_dir, + jpg_dir=jpg_dir, + destination_dir_name="raws-to-delete", + should_move=lambda raw_file, jpg_files: ( + not has_matching_jpg( + raw_file, + jpg_files, ) - continue - - if dry_run: - dry_run_count += 1 - report( - "info", - f"[DRY RUN] Would move {raw_file.name} -> {trash_dir}", - ) - continue - - trash_dir.mkdir(parents=True, exist_ok=True) - shutil.move(str(raw_file), str(target_file)) - moved_count += 1 - - report("info", f"Moved {raw_file.name} -> {trash_dir}") - - # Summary - - if dry_run: - report("summary", f"Dry run complete: would move {dry_run_count} file(s)") - else: - report("summary", f"Moved {moved_count} file(s)") - - if skipped_existing_count: - report( - "warning", - f"Skipped {skipped_existing_count} file(s): " - "already exist in raws-to-delete", - ) + ), + report=report, + dry_run=dry_run, + existing_warning_message=None, + ) diff --git a/src/photo_tools/commands/keep_five_star_raws.py b/src/photo_tools/commands/keep_five_star_raws.py new file mode 100644 index 0000000..383fcc0 --- /dev/null +++ b/src/photo_tools/commands/keep_five_star_raws.py @@ -0,0 +1,55 @@ +import logging +from collections.abc import Callable +from pathlib import Path + +from photo_tools.image.metadata import get_exif_metadata, parse_rating +from photo_tools.image.raw_utils import get_matching_jpgs, move_raws_by_rule + +logger = logging.getLogger(__name__) + +RAW_EXTENSIONS = {".raf"} +JPG_EXTENSIONS = {".jpg", ".jpeg"} + +Reporter = Callable[[str, str], None] +RawMatcher = Callable[[Path, list[Path]], bool] + + +def has_matching_five_star_jpg( + raw_file: Path, + jpg_files: list[Path], +) -> bool: + matching_jpgs = get_matching_jpgs(raw_file, jpg_files) + + for jpg_file in matching_jpgs: + try: + metadata = get_exif_metadata(jpg_file) + rating = parse_rating(metadata) + except Exception as e: + logger.debug( + "Could not read rating for %s (%s)", + jpg_file.name, + e, + ) + continue + + if rating == 5: + return True + + return False + + +def keep_five_star_raws( + raw_dir: str, + jpg_dir: str, + report: Reporter, + dry_run: bool = False, +) -> None: + move_raws_by_rule( + raw_dir=raw_dir, + jpg_dir=jpg_dir, + destination_dir_name="raws-5-star", + should_move=has_matching_five_star_jpg, + report=report, + dry_run=dry_run, + existing_warning_message=None, + ) diff --git a/src/photo_tools/image/metadata.py b/src/photo_tools/image/metadata.py index 8a1cb35..3d657e9 100644 --- a/src/photo_tools/image/metadata.py +++ b/src/photo_tools/image/metadata.py @@ -1,9 +1,12 @@ import json +import logging import subprocess from datetime import datetime from pathlib import Path from typing import Any, Dict +logger = logging.getLogger(__name__) + def get_exif_metadata(file_path: Path) -> Dict[str, Any]: result = subprocess.run( @@ -30,3 +33,40 @@ def get_image_date(file_path: Path) -> datetime: return datetime.strptime(value, "%Y:%m:%d %H:%M:%S") raise ValueError("No usable date field found") + + +def parse_rating(metadata: dict[str, str]) -> int | None: + rating_keys = ( + "XMP:Rating", + "Rating", + "XMP-xmp:Rating", + ) + + for key in rating_keys: + value = metadata.get(key) + + if value in (None, ""): + continue + + try: + rating = int(str(value).strip()) + + logger.debug( + "Parsed rating %s from metadata key '%s'", + rating, + key, + ) + + return rating + + except ValueError: + logger.warning( + "Invalid rating value '%s' found in key '%s'", + value, + key, + ) + return None + + logger.debug("No rating metadata found") + + return None diff --git a/src/photo_tools/image/raw_utils.py b/src/photo_tools/image/raw_utils.py new file mode 100644 index 0000000..485daef --- /dev/null +++ b/src/photo_tools/image/raw_utils.py @@ -0,0 +1,96 @@ +import logging +import shutil +from collections.abc import Callable +from pathlib import Path + +from photo_tools.core.validation import validate_input_dir + +logger = logging.getLogger(__name__) + +RAW_EXTENSIONS = {".raf"} +JPG_EXTENSIONS = {".jpg", ".jpeg"} + +Reporter = Callable[[str, str], None] +RawMatcher = Callable[[Path, list[Path]], bool] + + +def move_raws_by_rule( + raw_dir: str, + jpg_dir: str, + destination_dir_name: str, + should_move: RawMatcher, + report: Reporter, + dry_run: bool = False, + existing_warning_message: str | None = None, +) -> None: + raw_path = Path(raw_dir) + jpg_path = Path(jpg_dir) + destination_dir = raw_path / destination_dir_name + + validate_input_dir(raw_path) + validate_input_dir(jpg_path) + + moved_count = 0 + dry_run_count = 0 + skipped_existing_count = 0 + + jpg_files = [ + f + for f in jpg_path.iterdir() + if f.is_file() and f.suffix.lower() in JPG_EXTENSIONS + ] + + for raw_file in raw_path.iterdir(): + if not raw_file.is_file(): + continue + + if raw_file.suffix.lower() not in RAW_EXTENSIONS: + continue + + if not should_move(raw_file, jpg_files): + continue + + target_file = destination_dir / raw_file.name + + if target_file.exists(): + skipped_existing_count += 1 + report( + "warning", + existing_warning_message + or f"Skipping {raw_file.name}: already in {destination_dir_name}", + ) + continue + + if dry_run: + dry_run_count += 1 + report( + "info", + f"[DRY RUN] Would move {raw_file.name} -> {destination_dir}", + ) + continue + + destination_dir.mkdir(parents=True, exist_ok=True) + shutil.move(str(raw_file), str(target_file)) + moved_count += 1 + report("info", f"Moved {raw_file.name} -> {destination_dir}") + + if dry_run: + report("summary", f"Dry run complete: would move {dry_run_count} file(s)") + else: + report("summary", f"Moved {moved_count} file(s)") + + if skipped_existing_count: + report( + "warning", + f"Skipped {skipped_existing_count} file(s): " + f"already exist in {destination_dir_name}", + ) + + +def get_matching_jpgs( + raw_file: Path, + jpg_files: list[Path], +) -> list[Path]: + raw_stem = raw_file.stem.lower() + + return [jpg for jpg in jpg_files if jpg.name.lower().startswith(raw_stem)] diff --git a/tests/test_keep_five_star_raws.py b/tests/test_keep_five_star_raws.py new file mode 100644 index 0000000..fc3ff7e --- /dev/null +++ b/tests/test_keep_five_star_raws.py @@ -0,0 +1,350 @@ +import photo_tools.commands.keep_five_star_raws as keep_starred_module +from photo_tools.commands.keep_five_star_raws import keep_five_star_raws + + +def noop_report(level: str, message: str) -> None: + pass + + +def test_dry_run_does_not_move_raw_when_matching_jpg_has_5_stars( + tmp_path, + monkeypatch, +): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + raw_file = raw_dir / "photo.raf" + jpg_file = jpg_dir / "photo.jpg" + + raw_file.write_text("fake raw content") + jpg_file.write_text("fake jpg content") + + monkeypatch.setattr( + keep_starred_module, + "get_exif_metadata", + lambda file_path: {"Rating": "5"}, + ) + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=True, + ) + + assert raw_file.exists() + assert not (raw_dir / "raws-5-star" / "photo.raf").exists() + + +def test_moves_raw_when_matching_jpg_has_5_stars(tmp_path, monkeypatch): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + raw_file = raw_dir / "photo.raf" + jpg_file = jpg_dir / "photo.jpg" + + raw_file.write_text("fake raw content") + jpg_file.write_text("fake jpg content") + + monkeypatch.setattr( + keep_starred_module, + "get_exif_metadata", + lambda file_path: {"Rating": "5"}, + ) + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + moved_file = raw_dir / "raws-5-star" / "photo.raf" + + assert not raw_file.exists() + assert moved_file.exists() + + +def test_keeps_raw_when_matching_jpg_does_not_have_5_stars(tmp_path, monkeypatch): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + raw_file = raw_dir / "photo.raf" + jpg_file = jpg_dir / "photo.jpg" + + raw_file.write_text("fake raw content") + jpg_file.write_text("fake jpg content") + + monkeypatch.setattr( + keep_starred_module, + "get_exif_metadata", + lambda file_path: {"Rating": "4"}, + ) + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + assert raw_file.exists() + assert jpg_file.exists() + assert not (raw_dir / "raws-5-star" / "photo.raf").exists() + + +def test_keeps_raw_when_no_matching_jpg_exists(tmp_path, monkeypatch): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + raw_file = raw_dir / "photo.raf" + other_jpg = jpg_dir / "other.jpg" + + raw_file.write_text("fake raw content") + other_jpg.write_text("fake jpg content") + + monkeypatch.setattr( + keep_starred_module, + "get_exif_metadata", + lambda file_path: {"Rating": "5"}, + ) + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + assert raw_file.exists() + assert not (raw_dir / "raws-5-star" / "photo.raf").exists() + + +def test_moves_raw_when_matching_jpg_starts_with_same_stem_and_has_5_stars( + tmp_path, + monkeypatch, +): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + raw_file = raw_dir / "photo.raf" + jpg_file = jpg_dir / "photo_edit.jpg" + + raw_file.write_text("fake raw content") + jpg_file.write_text("fake jpg content") + + monkeypatch.setattr( + keep_starred_module, + "get_exif_metadata", + lambda file_path: {"Rating": "5"}, + ) + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + assert not raw_file.exists() + assert (raw_dir / "raws-5-star" / "photo.raf").exists() + + +def test_moves_only_raw_files_with_matching_5_star_jpgs(tmp_path, monkeypatch): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + starred_raw = raw_dir / "starred.raf" + unstarred_raw = raw_dir / "unstarred.raf" + + starred_jpg = jpg_dir / "starred.jpg" + unstarred_jpg = jpg_dir / "unstarred.jpg" + + starred_raw.write_text("starred raw") + unstarred_raw.write_text("unstarred raw") + starred_jpg.write_text("starred jpg") + unstarred_jpg.write_text("unstarred jpg") + + def fake_get_exif_metadata(file_path): + if file_path.name == "starred.jpg": + return {"Rating": "5"} + return {"Rating": "3"} + + monkeypatch.setattr( + keep_starred_module, + "get_exif_metadata", + fake_get_exif_metadata, + ) + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + assert not starred_raw.exists() + assert (raw_dir / "raws-5-star" / "starred.raf").exists() + + assert unstarred_raw.exists() + assert not (raw_dir / "raws-5-star" / "unstarred.raf").exists() + + +def test_ignores_non_raw_files_in_raw_directory(tmp_path, monkeypatch): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + jpg_file = raw_dir / "photo.jpg" + txt_file = raw_dir / "notes.txt" + rated_jpg = jpg_dir / "photo.jpg" + + jpg_file.write_text("fake jpg content") + txt_file.write_text("notes") + rated_jpg.write_text("rated jpg") + + monkeypatch.setattr( + keep_starred_module, + "get_exif_metadata", + lambda file_path: {"Rating": "5"}, + ) + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + assert jpg_file.exists() + assert txt_file.exists() + assert not (raw_dir / "raws-5-star" / "photo.jpg").exists() + assert not (raw_dir / "raws-5-star" / "notes.txt").exists() + + +def test_skips_file_when_destination_already_exists(tmp_path, monkeypatch): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + keep_dir = raw_dir / "raws-5-star" + + raw_dir.mkdir() + jpg_dir.mkdir() + keep_dir.mkdir() + + source_file = raw_dir / "photo.raf" + existing_file = keep_dir / "photo.raf" + jpg_file = jpg_dir / "photo.jpg" + + source_file.write_text("new raw") + existing_file.write_text("existing raw") + jpg_file.write_text("fake jpg") + + monkeypatch.setattr( + keep_starred_module, + "get_exif_metadata", + lambda file_path: {"Rating": "5"}, + ) + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + assert source_file.exists() + assert existing_file.exists() + assert existing_file.read_text() == "existing raw" + + +def test_handles_empty_directories(tmp_path): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + assert not any(raw_dir.iterdir()) + + +def test_ignores_nested_directories(tmp_path): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + nested_dir = raw_dir / "nested" + + raw_dir.mkdir() + jpg_dir.mkdir() + nested_dir.mkdir() + + nested_raw = nested_dir / "photo.raf" + nested_raw.write_text("fake raw content") + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + assert nested_raw.exists() + assert not (raw_dir / "raws-5-star" / "photo.raf").exists() + + +def test_keeps_raw_when_rating_cannot_be_read(tmp_path, monkeypatch): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + raw_file = raw_dir / "photo.raf" + jpg_file = jpg_dir / "photo.jpg" + + raw_file.write_text("fake raw content") + jpg_file.write_text("fake jpg content") + + def fake_get_exif_metadata(file_path): + raise ValueError("bad metadata") + + monkeypatch.setattr( + keep_starred_module, + "get_exif_metadata", + fake_get_exif_metadata, + ) + + keep_five_star_raws( + str(raw_dir), + str(jpg_dir), + report=noop_report, + dry_run=False, + ) + + assert raw_file.exists() + assert not (raw_dir / "raws-5-star" / "photo.raf").exists()