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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ typecheck:
test:
pytest

check: lint test format typecheck
check: format lint typecheck test
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ photo-tools raws <INPUT_DIR>
photo-tools clean-raws <RAW_DIR> <JPG_DIR>
```

### 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 <RAW_DIR> <JPG_DIR>
```

### Optimise images (`optimise`)

- Resize images to a maximum width of `2500px`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 35 additions & 0 deletions src/photo_tools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "
Expand Down
91 changes: 22 additions & 69 deletions src/photo_tools/commands/clean_unpaired_raws.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
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__)

RAW_EXTENSIONS = {".raf"}
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(
Expand All @@ -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,
)
55 changes: 55 additions & 0 deletions src/photo_tools/commands/keep_five_star_raws.py
Original file line number Diff line number Diff line change
@@ -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,
)
40 changes: 40 additions & 0 deletions src/photo_tools/image/metadata.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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
Loading
Loading