diff --git a/osxphotos/cli/import_cli.py b/osxphotos/cli/import_cli.py index 97af9972..533970c9 100644 --- a/osxphotos/cli/import_cli.py +++ b/osxphotos/cli/import_cli.py @@ -61,7 +61,11 @@ metadata_from_sidecar, ) from osxphotos.photoinfo import PhotoInfoNone -from osxphotos.photoinfo_file import render_photo_template, strip_edited_suffix +from osxphotos.photoinfo_file import ( + PhotoInfoFromFile, + render_photo_template, + strip_edited_suffix, +) from osxphotos.photosalbum import PhotosAlbumPhotoScript from osxphotos.phototemplate import PhotoTemplate, RenderOptions from osxphotos.sqlite_utils import sqlite_columns @@ -502,6 +506,35 @@ def set_photo_location( return location +def set_photo_favorite( + photo: Photo | None, + filepath: pathlib.Path, + sidecar_filepath: pathlib.Path | None, + exiftool_path: str, + favorite_rating: int | None, + verbose: Callable[..., None], + dry_run: bool, +): + """Set favorite status of photo based on XMP:Rating value""" + rating = get_photo_rating(filepath, sidecar_filepath, exiftool_path) + if rating is not None and rating >= favorite_rating: + verbose( + f"Setting favorite status of photo [filename]{filepath.name}[/] (XMP:Rating=[num]{rating}[/])" + ) + if photo and not dry_run: + photo.favorite = True + + +def get_photo_rating( + filepath: pathlib.Path, sidecar: pathlib.Path | None, exiftool_path: str +) -> int | None: + """Get XMP:Rating from file""" + photoinfo = PhotoInfoFromFile( + filepath, exiftool=exiftool_path, sidecar=str(sidecar) if sidecar else None + ) + return photoinfo.rating + + def combine_date_time( photo: Photo | None, filepath: str | pathlib.Path, @@ -1070,7 +1103,7 @@ def collect_files_to_import( else: continue - files_to_import = [pathlib.Path(f) for f in files_to_import] + files_to_import = [pathlib.Path(f).absolute() for f in files_to_import] # strip any sidecar files files_to_import = [ @@ -1198,6 +1231,7 @@ def import_files( edited_suffix: str | None, exiftool: bool, exiftool_path: str, + favorite_rating: int | None, sidecar: bool, sidecar_ignore_date: bool, sidecar_filename_template: str, @@ -1402,6 +1436,17 @@ def import_files( if location: set_photo_location(photo, filepath, location, verbose, dry_run) + if favorite_rating: + set_photo_favorite( + photo, + filepath, + sidecar_file, + exiftool_path, + favorite_rating, + verbose, + dry_run, + ) + if parse_date: set_photo_date_from_filename( photo, filepath.name, filepath.name, parse_date, verbose, dry_run @@ -1856,6 +1901,16 @@ def get_help(self, ctx): "Longitude is a number in the range -180.0 to 180.0; " "positive longitudes are east of the Prime Meridian; negative longitudes are west of the Prime Meridian.", ) +@click.option( + "--favorite-rating", + "-G", + metavar="RATING", + type=click.IntRange(1, 5), + help="If XMP:Rating is set to RATING or higher, mark imported photo as a favorite. " + "RATING must be in range 1 to 5. " + "XMP:Rating will be read from asset's metadata or from sidecar if --sidecar, --sidecare-filename is used. " + "Requires that exiftool be installed to read the rating from the asset's XMP data.", +) @click.option( "--parse-date", "-P", @@ -2153,6 +2208,7 @@ def import_main( edited_suffix: str | None, exiftool: bool, exiftool_path: str | None, + favorite_rating: int | None, files: tuple[str, ...], glob: tuple[str, ...], keyword: tuple[str, ...], @@ -2211,6 +2267,7 @@ def import_cli( edited_suffix: str | None = None, exiftool: bool = False, exiftool_path: str | None = None, + favorite_rating: int | None = None, files: tuple[str, ...] = (), glob: tuple[str, ...] = (), keyword: tuple[str, ...] = (), @@ -2344,6 +2401,7 @@ def import_cli( edited_suffix=edited_suffix, exiftool=exiftool, exiftool_path=exiftool_path, + favorite_rating=favorite_rating, sidecar=sidecar, sidecar_ignore_date=sidecar_ignore_date, sidecar_filename_template=sidecar_filename_template, diff --git a/osxphotos/metadata_reader.py b/osxphotos/metadata_reader.py index 0bc432af..9fdc3c9d 100644 --- a/osxphotos/metadata_reader.py +++ b/osxphotos/metadata_reader.py @@ -34,6 +34,7 @@ class MetaData: keywords: list of keywords for photo location: tuple of lat, long or None, None if not set favorite: bool, True if photo marked favorite + rating: int, rating of photo 0-5 persons: list of persons in photo date: datetime for photo as naive datetime.datetime in local timezone or None if not set timezone: timezone or None of original date (before conversion to local naive datetime) @@ -45,6 +46,7 @@ class MetaData: keywords: list[str] location: tuple[Optional[float], Optional[float]] favorite: bool = False + rating: int = 0 persons: list[str] = field(default_factory=list) date: datetime.datetime | None = None timezone: datetime.tzinfo | None = None @@ -212,6 +214,7 @@ def metadata_from_sidecar( description: str, XMP:Description, IPTC:Caption-Abstract, EXIF:ImageDescription, QuickTime:Description keywords: str, XMP:Subject, XMP:TagsList, IPTC:Keywords (QuickTime:Keywords not supported) location: Tuple[lat, lon], EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef, EXIF:GPSLongitude, QuickTime:GPSCoordinates, UserData:GPSCoordinates + rating: int, XMP:Rating Raises: ValueError if error reading sidecar file @@ -318,6 +321,8 @@ def metadata_from_metadata_dict(metadata: dict) -> MetaData: or metadata.get("Keywords") ) + rating = metadata.get("XMP:Rating") or metadata.get("Rating") + persons = metadata.get("XMP:PersonInImage", []) or metadata.get("PersonInImage", []) if persons and not isinstance(persons, (tuple, list)): persons = [persons] @@ -344,6 +349,7 @@ def metadata_from_metadata_dict(metadata: dict) -> MetaData: description=description, keywords=keywords, location=location, + rating = rating or 0, favorite=False, persons=persons, date=date, diff --git a/osxphotos/photoinfo_file.py b/osxphotos/photoinfo_file.py index 38564107..fea9ee31 100644 --- a/osxphotos/photoinfo_file.py +++ b/osxphotos/photoinfo_file.py @@ -108,6 +108,11 @@ def description(self) -> str | None: """description of picture""" return self._metadata.description + @property + def rating(self) -> int: + """rating of picture; reads XMP:Rating from the photo or sidecar file if available, else returns 0""" + return self._metadata.rating + @property def fingerprint(self) -> str | None: """Returns fingerprint of original photo as a string or None if not on macOS""" diff --git a/tests/test-images/IMG_4179.jpeg.json b/tests/test-images/IMG_4179.jpeg.json index 4767bcd3..75a5f1f2 100644 --- a/tests/test-images/IMG_4179.jpeg.json +++ b/tests/test-images/IMG_4179.jpeg.json @@ -1,25 +1,26 @@ [ - { - "SourceFile": "IMG_4179.jpeg", - "ExifToolVersion": "12.00", - "FileName": "IMG_4179.jpeg", - "ImageDescription": "Image Description", - "Description": "Image Description", - "Caption-Abstract": "Image Description", - "Title": "Image Title", - "ObjectName": "Image Title", - "Keywords": ["nature"], - "Subject": ["nature"], - "TagsList": ["nature"], - "GPSLatitude": 33.71506, - "GPSLongitude": -118.31967, - "GPSLatitudeRef": "N", - "GPSLongitudeRef": "W", - "DateTimeOriginal": "2021:04:08 16:04:55", - "CreateDate": "2021:04:08 16:04:55", - "OffsetTimeOriginal": "-07:00", - "DateCreated": "2021:04:08", - "TimeCreated": "16:04:55-07:00", - "ModifyDate": "2021:04:08 16:04:55" - } -] \ No newline at end of file + { + "SourceFile": "IMG_4179.jpeg", + "ExifToolVersion": "12.00", + "FileName": "IMG_4179.jpeg", + "ImageDescription": "Image Description", + "Description": "Image Description", + "Caption-Abstract": "Image Description", + "Title": "Image Title", + "ObjectName": "Image Title", + "Keywords": ["nature"], + "Subject": ["nature"], + "TagsList": ["nature"], + "GPSLatitude": 33.71506, + "GPSLongitude": -118.31967, + "GPSLatitudeRef": "N", + "GPSLongitudeRef": "W", + "DateTimeOriginal": "2021:04:08 16:04:55", + "CreateDate": "2021:04:08 16:04:55", + "OffsetTimeOriginal": "-07:00", + "DateCreated": "2021:04:08", + "TimeCreated": "16:04:55-07:00", + "ModifyDate": "2021:04:08 16:04:55", + "Rating": 5 + } +] diff --git a/tests/test_cli_import.py b/tests/test_cli_import.py index 126ddd1f..d147a1e9 100644 --- a/tests/test_cli_import.py +++ b/tests/test_cli_import.py @@ -846,11 +846,43 @@ def test_import_sidecar_filename(): assert photo_1.title == TEST_DATA[TEST_IMAGE_1]["sidecar"]["title"] assert photo_1.description == TEST_DATA[TEST_IMAGE_1]["sidecar"]["description"] assert photo_1.keywords == TEST_DATA[TEST_IMAGE_1]["sidecar"]["keywords"] + assert not photo_1.favorite lat, lon = photo_1.location assert lat == approx(TEST_DATA[TEST_IMAGE_1]["sidecar"]["lat"]) assert lon == approx(TEST_DATA[TEST_IMAGE_1]["sidecar"]["lon"]) +@pytest.mark.test_import +def test_import_favorite_rating(): + """Test import file with --favorite-rating""" + cwd = os.getcwd() + test_image_1 = os.path.join(cwd, TEST_IMAGE_1) + runner = CliRunner() + result = runner.invoke( + import_main, + [ + "--verbose", + "--clear-metadata", + "--sidecar", + "--favorite-rating", + 5, + test_image_1, + ], + terminal_width=TERMINAL_WIDTH, + ) + + assert result.exit_code == 0 + assert "Setting favorite status" in result.output + + import_data = parse_import_output(result.output) + file_1 = pathlib.Path(test_image_1).name + uuid_1 = import_data[file_1] + photo_1 = Photo(uuid_1) + + assert photo_1.filename == file_1 + assert photo_1.favorite + + @pytest.mark.test_import def test_import_glob(): """Test import with --glob"""