Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,18 +104,18 @@ photo-tools separate-raws <INPUT_DIR>
photo-tools separate-raws <INPUT_DIR> --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 <RAW_DIR> <JPG_DIR>
photo-tools clean-unpaired-raws <RAW_DIR> <JPG_DIR>
```

```shell
photo-tools soft-delete-unpaired-raws <RAW_DIR> <JPG_DIR> --dry-run
photo-tools clean-unpaired-raws <RAW_DIR> <JPG_DIR> --dry-run
```

#### optimise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/photo_tools/cli.py
Original file line number Diff line number Diff line change
@@ -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.")

Expand Down Expand Up @@ -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.",
Expand All @@ -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(
Expand Down
169 changes: 169 additions & 0 deletions tests/test_clean_unpaired_raws.py
Original file line number Diff line number Diff line change
@@ -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()
Loading