diff --git a/README.md b/README.md index 7386a34..717f74c 100644 --- a/README.md +++ b/README.md @@ -104,18 +104,18 @@ photo-tools separate-raws photo-tools separate-raws --dry-run ``` -#### soft-delete-unpaired-raws +#### clean-unpaired-raws - Move RAW files to `raws-to-delete/` if no matching JPG (same prefix) exists - Matching is based on filename prefix (e.g. `abcd.RAF` matches `abcd_edit.jpg`) - Files are moved (not deleted), making the operation reversible ```shell -photo-tools soft-delete-unpaired-raws +photo-tools clean-unpaired-raws ``` ```shell -photo-tools soft-delete-unpaired-raws --dry-run +photo-tools clean-unpaired-raws --dry-run ``` #### optimise diff --git a/src/photo_tools/soft_delete_unpaired_raws.py b/src/photo_tools/clean_unpaired_raws.py similarity index 74% rename from src/photo_tools/soft_delete_unpaired_raws.py rename to src/photo_tools/clean_unpaired_raws.py index 1a6f69a..e315718 100644 --- a/src/photo_tools/soft_delete_unpaired_raws.py +++ b/src/photo_tools/clean_unpaired_raws.py @@ -2,13 +2,15 @@ import shutil from pathlib import Path +from photo_tools.core.validation import validate_input_dir + logger = logging.getLogger(__name__) RAW_EXTENSIONS = {".raf"} JPG_EXTENSIONS = {".jpg", ".jpeg"} -def soft_delete_unpaired_raws( +def clean_unpaired_raws( raw_dir: str, jpg_dir: str, dry_run: bool = False, @@ -17,17 +19,8 @@ def soft_delete_unpaired_raws( jpg_path = Path(jpg_dir) trash_dir = raw_path / "raws-to-delete" - if not raw_path.exists(): - raise FileNotFoundError(f"RAW path does not exist: {raw_path}") - - if not jpg_path.exists(): - raise FileNotFoundError(f"JPG path does not exist: {jpg_path}") - - if not raw_path.is_dir(): - raise NotADirectoryError(f"RAW path is not a directory: {raw_path}") - - if not jpg_path.is_dir(): - raise NotADirectoryError(f"JPG path is not a directory: {jpg_path}") + validate_input_dir(raw_path) + validate_input_dir(jpg_path) jpg_files = [ f diff --git a/src/photo_tools/cli.py b/src/photo_tools/cli.py index 4516909..056441d 100644 --- a/src/photo_tools/cli.py +++ b/src/photo_tools/cli.py @@ -1,12 +1,12 @@ import typer +from photo_tools.clean_unpaired_raws import clean_unpaired_raws from photo_tools.core.dependencies import validate_feature from photo_tools.exceptions import MissingDependencyError from photo_tools.logging_config import setup_logging from photo_tools.optimise import optimise from photo_tools.organise_by_date import organise_by_date from photo_tools.separate_raws import separate_raws -from photo_tools.soft_delete_unpaired_raws import soft_delete_unpaired_raws app = typer.Typer(help="CLI tools for organising and optimising photography workflows.") @@ -69,10 +69,10 @@ def separate_raws_cmd( @app.command( - "soft-delete-unpaired-raws", - help="Move RAW files without a matching JPG (by filename) to 'raws-to-delete'.", + "clean-unpaired-raws", + help="Move RAW files without matching JPGs to 'raws-to-delete'.", ) -def soft_delete_unpaired_raws_cmd( +def clean_unpaired_raws_cmd( raw_dir: str = typer.Argument( ..., help="Directory containing RAW files.", @@ -84,10 +84,10 @@ def soft_delete_unpaired_raws_cmd( dry_run: bool = typer.Option( False, "--dry-run", - help="Show which RAW files would be moved without making changes.", + help="Preview changes without moving files.", ), ) -> None: - soft_delete_unpaired_raws(raw_dir, jpg_dir, dry_run) + clean_unpaired_raws(raw_dir, jpg_dir, dry_run) @app.command( diff --git a/tests/test_clean_unpaired_raws.py b/tests/test_clean_unpaired_raws.py new file mode 100644 index 0000000..e51b8d4 --- /dev/null +++ b/tests/test_clean_unpaired_raws.py @@ -0,0 +1,169 @@ +from photo_tools.clean_unpaired_raws import clean_unpaired_raws + + +def test_dry_run_does_not_move_unpaired_raw_files(tmp_path): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + raw_file = raw_dir / "photo.raf" + raw_file.write_text("fake raw content") + + clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=True) + + assert raw_file.exists() + assert not (raw_dir / "raws-to-delete" / "photo.raf").exists() + + +def test_moves_unpaired_raw_file_into_raws_to_delete_folder(tmp_path): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + raw_file = raw_dir / "photo.raf" + raw_file.write_text("fake raw content") + + clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + + moved_file = raw_dir / "raws-to-delete" / "photo.raf" + + assert not raw_file.exists() + assert moved_file.exists() + + +def test_keeps_raw_file_when_matching_jpg_exists(tmp_path): + 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") + + clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + + assert raw_file.exists() + assert jpg_file.exists() + assert not (raw_dir / "raws-to-delete" / "photo.raf").exists() + + +def test_keeps_raw_file_when_matching_jpg_starts_with_same_stem(tmp_path): + 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") + + clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + + assert raw_file.exists() + assert not (raw_dir / "raws-to-delete" / "photo.raf").exists() + + +def test_moves_only_unpaired_raw_files(tmp_path): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + + raw_dir.mkdir() + jpg_dir.mkdir() + + matched_raw = raw_dir / "matched.raf" + unmatched_raw = raw_dir / "unmatched.raf" + matching_jpg = jpg_dir / "matched.jpg" + + matched_raw.write_text("matched raw") + unmatched_raw.write_text("unmatched raw") + matching_jpg.write_text("matching jpg") + + clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + + assert matched_raw.exists() + assert not unmatched_raw.exists() + assert (raw_dir / "raws-to-delete" / "unmatched.raf").exists() + + +def test_ignores_non_raw_files_in_raw_directory(tmp_path): + 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" + + jpg_file.write_text("fake jpg content") + txt_file.write_text("notes") + + clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + + assert jpg_file.exists() + assert txt_file.exists() + assert not (raw_dir / "raws-to-delete" / "photo.jpg").exists() + assert not (raw_dir / "raws-to-delete" / "notes.txt").exists() + + +def test_skips_file_when_destination_already_exists(tmp_path): + raw_dir = tmp_path / "raws" + jpg_dir = tmp_path / "jpgs" + trash_dir = raw_dir / "raws-to-delete" + + raw_dir.mkdir() + jpg_dir.mkdir() + trash_dir.mkdir() + + source_file = raw_dir / "photo.raf" + existing_file = trash_dir / "photo.raf" + + source_file.write_text("new raw") + existing_file.write_text("existing raw") + + clean_unpaired_raws(str(raw_dir), str(jpg_dir), 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() + + clean_unpaired_raws(str(raw_dir), str(jpg_dir), 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") + + clean_unpaired_raws(str(raw_dir), str(jpg_dir), dry_run=False) + + assert nested_raw.exists() + assert not (raw_dir / "raws-to-delete" / "photo.raf").exists()