From 663e33bc1709f767e1a08242f6bfe86a3fc78552 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sun, 1 Nov 2020 09:13:45 -0800 Subject: [PATCH] Added --ignore-date-modified flag, issue #247 --- README.md | 5 + osxphotos/__main__.py | 14 +++ osxphotos/_version.py | 2 +- osxphotos/photoinfo/_photoinfo_export.py | 149 +++++++++++++++++------ osxphotos/photoinfo/photoinfo.py | 1 + tests/test_cli.py | 50 ++++++++ tests/test_export_catalina_10_15_7.py | 42 +++++++ 7 files changed, 224 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 60e56b01..6089576d 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,11 @@ Options: exiftool may be installed from https://exiftool.org/. Cannot be used with --export-as-hardlink. + --ignore-date-modified If used with --exiftool or --sidecar, will + ignore the photo modification date and set + EXIF:ModifyDate to EXIF:DateTimeOriginal; + this is consistent with how Photos handles + the EXIF:ModifyDate tag. --directory DIRECTORY Optional template for specifying name of output directory in the form '{name,DEFAULT}'. See below for additional diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index f141852b..38244c4f 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1290,6 +1290,13 @@ def query( "exiftool may be installed from https://exiftool.org/. " "Cannot be used with --export-as-hardlink.", ) +@click.option( + "--ignore-date-modified", + is_flag=True, + help="If used with --exiftool or --sidecar, will ignore the photo " + "modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; " + "this is consistent with how Photos handles the EXIF:ModifyDate tag.", +) @click.option( "--directory", metavar="DIRECTORY", @@ -1389,6 +1396,7 @@ def export( download_missing, dest, exiftool, + ignore_date_modified, portrait, not_portrait, screenshot, @@ -1663,6 +1671,7 @@ def export( use_photos_export=use_photos_export, convert_to_jpeg=convert_to_jpeg, jpeg_quality=jpeg_quality, + ignore_date_modified=ignore_date_modified, ) results_exported.extend(results.exported) results_new.extend(results.new) @@ -1712,6 +1721,7 @@ def export( use_photos_export=use_photos_export, convert_to_jpeg=convert_to_jpeg, jpeg_quality=jpeg_quality, + ignore_date_modified=ignore_date_modified, ) results_exported.extend(results.exported) results_new.extend(results.new) @@ -2218,6 +2228,7 @@ def export_photo( use_photos_export=False, convert_to_jpeg=False, jpeg_quality=1.0, + ignore_date_modified=False, ): """ Helper function for export that does the actual export @@ -2251,6 +2262,7 @@ def export_photo( use_photos_export: boolean; if True forces the use of AppleScript to export even if photo not missing convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression. + ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set Returns: list of path(s) of exported photo or None if photo was missing @@ -2339,6 +2351,7 @@ def export_photo( touch_file=touch_file, convert_to_jpeg=convert_to_jpeg, jpeg_quality=jpeg_quality, + ignore_date_modified=ignore_date_modified, ) results_exported.extend(export_results.exported) @@ -2400,6 +2413,7 @@ def export_photo( touch_file=touch_file, convert_to_jpeg=convert_to_jpeg, jpeg_quality=jpeg_quality, + ignore_date_modified=ignore_date_modified, ) results_exported.extend(export_results_edited.exported) diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 2b937502..3cce7bcf 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,4 +1,4 @@ """ version info """ -__version__ = "0.36.4" +__version__ = "0.36.5" diff --git a/osxphotos/photoinfo/_photoinfo_export.py b/osxphotos/photoinfo/_photoinfo_export.py index 555f6da8..4bb0fd05 100644 --- a/osxphotos/photoinfo/_photoinfo_export.py +++ b/osxphotos/photoinfo/_photoinfo_export.py @@ -5,6 +5,7 @@ _export_photo _write_exif_data _exiftool_json_sidecar + _exiftool_dict _xmp_sidecar _write_sidecar """ @@ -308,6 +309,7 @@ def export2( touch_file=False, convert_to_jpeg=False, jpeg_quality=1.0, + ignore_date_modified=False, ): """ export photo, like export but with update and dry_run options dest: must be valid destination path or exception raised @@ -350,6 +352,7 @@ def export2( touch_file: (boolean, default=False); if True, sets file's modification time upon photo date convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression. + ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set Returns: ExportResults namedtuple with fields: exported, new, updated, skipped where each field is a list of file paths @@ -698,6 +701,7 @@ def export2( use_persons_as_keywords=use_persons_as_keywords, keyword_template=keyword_template, description_template=description_template, + ignore_date_modified=ignore_date_modified, ) if not dry_run: try: @@ -743,6 +747,7 @@ def export2( use_persons_as_keywords=use_persons_as_keywords, keyword_template=keyword_template, description_template=description_template, + ignore_date_modified=ignore_date_modified, ) )[0] if old_data != current_data: @@ -758,6 +763,7 @@ def export2( use_persons_as_keywords=use_persons_as_keywords, keyword_template=keyword_template, description_template=description_template, + ignore_date_modified=ignore_date_modified, ) export_db.set_exifdata_for_file( exported_file, @@ -766,6 +772,7 @@ def export2( use_persons_as_keywords=use_persons_as_keywords, keyword_template=keyword_template, description_template=description_template, + ignore_date_modified=ignore_date_modified, ), ) export_db.set_stat_exif_for_file( @@ -781,6 +788,7 @@ def export2( use_persons_as_keywords=use_persons_as_keywords, keyword_template=keyword_template, description_template=description_template, + ignore_date_modified=ignore_date_modified, ) export_db.set_exifdata_for_file( @@ -790,6 +798,7 @@ def export2( use_persons_as_keywords=use_persons_as_keywords, keyword_template=keyword_template, description_template=description_template, + ignore_date_modified=ignore_date_modified, ), ) export_db.set_stat_exif_for_file( @@ -997,21 +1006,30 @@ def _write_exif_data( use_persons_as_keywords=False, keyword_template=None, description_template=None, + ignore_date_modified=False, ): """ write exif data to image file at filepath - filepath: full path to the image file """ + + Args: + filepath: full path to the image file + use_albums_as_keywords: treat album names as keywords + use_persons_as_keywords: treat person names as keywords + keyword_template: (list of strings); list of template strings to render as keywords + ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set + """ if not os.path.exists(filepath): raise FileNotFoundError(f"Could not find file {filepath}") exiftool = ExifTool(filepath) - exif_info = json.loads( - self._exiftool_json_sidecar( - use_albums_as_keywords=use_albums_as_keywords, - use_persons_as_keywords=use_persons_as_keywords, - keyword_template=keyword_template, - description_template=description_template, - ) - )[0] + exif_info = self._exiftool_dict( + use_albums_as_keywords=use_albums_as_keywords, + use_persons_as_keywords=use_persons_as_keywords, + keyword_template=keyword_template, + description_template=description_template, + ignore_date_modified=ignore_date_modified, + ) for exiftag, val in exif_info.items(): + if exiftag == "_CreatedBy": + continue if type(val) == list: # more than one, set first value the add additional values exiftool.setvalue(exiftag, val.pop(0)) @@ -1022,37 +1040,46 @@ def _write_exif_data( exiftool.setvalue(exiftag, val) -def _exiftool_json_sidecar( +def _exiftool_dict( self, use_albums_as_keywords=False, use_persons_as_keywords=False, keyword_template=None, description_template=None, + ignore_date_modified=False, ): - """ return json string of EXIF details in exiftool sidecar format - Does not include all the EXIF fields as those are likely already in the image + """ Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool. + Does not include all the EXIF fields as those are likely already in the image. + + Args: use_albums_as_keywords: treat album names as keywords use_persons_as_keywords: treat person names as keywords keyword_template: (list of strings); list of template strings to render as keywords - Exports the following: - FileName - ImageDescription - Description - Title - TagsList - Keywords (may include album name, person name, or template) - Subject - PersonInImage - GPSLatitude, GPSLongitude - GPSPosition - GPSLatitudeRef, GPSLongitudeRef - DateTimeOriginal - OffsetTimeOriginal - ModifyDate """ + description_template: (list of strings); list of template strings to render for the description + ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set + + Returns: dict with exiftool tags / values + + Exports the following: + EXIF:ImageDescription + XMP:Description (may include template) + XMP:Title + XMP:TagsList + IPTC:Keywords (may include album name, person name, or template) + XMP:Subject + XMP:PersonInImage + EXIF:GPSLatitude, EXIF:GPSLongitude + EXIF:GPSPosition + EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef + EXIF:DateTimeOriginal + EXIF:OffsetTimeOriginal + EXIF:ModifyDate + IPTC:DigitalCreationDate + IPTC:DateCreated + """ exif = {} exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos" - if description_template is not None: description = self.render_template( description_template, expand_inplace=True, inplace_sep=", " @@ -1114,15 +1141,16 @@ def _exiftool_json_sidecar( keyword_list.extend(rendered_keywords) if keyword_list: - exif["XMP:TagsList"] = exif["IPTC:Keywords"] = keyword_list + exif["XMP:TagsList"] = keyword_list.copy() + exif["IPTC:Keywords"] = keyword_list.copy() if person_list: - exif["XMP:PersonInImage"] = person_list + exif["XMP:PersonInImage"] = person_list.copy() if self.keywords or person_list: # Photos puts both keywords and persons in Subject when using "Export IPTC as XMP" - # only use Photos' keywords for subject - exif["XMP:Subject"] = list(self.keywords) + person_list + # only use Photos' keywords for subject (e.g. don't include template values) + exif["XMP:Subject"] = self.keywords.copy() + person_list.copy() # if self.favorite(): # exif["Rating"] = 5 @@ -1144,10 +1172,10 @@ def _exiftool_json_sidecar( # [IPTC] Digital Creation Date : 2020:10:30 # [IPTC] Date Created : 2020:10:30 # - # This code deviates from Photos in one regard: + # This code deviates from Photos in one regard: # if photo has modification date, use it otherwise use creation date date = self.date - + # exiftool expects format to "2015:01:18 12:00:00" datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S") exif["EXIF:DateTimeOriginal"] = datetimeoriginal @@ -1160,18 +1188,63 @@ def _exiftool_json_sidecar( offsettime = f"{offset[0]}{offset[1]}:{offset[2]}" exif["EXIF:OffsetTimeOriginal"] = offsettime - dateoriginal = date.strftime("%Y:%m:%d") + dateoriginal = date.strftime("%Y:%m:%d") exif["IPTC:DigitalCreationDate"] = dateoriginal exif["IPTC:DateCreated"] = dateoriginal - if self.date_modified is not None: + if self.date_modified is not None and not ignore_date_modified: exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S") else: exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S") + return exif - json_str = json.dumps([exif]) - return json_str + +def _exiftool_json_sidecar( + self, + use_albums_as_keywords=False, + use_persons_as_keywords=False, + keyword_template=None, + description_template=None, + ignore_date_modified=False, +): + """ Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool. + Does not include all the EXIF fields as those are likely already in the image. + + Args: + use_albums_as_keywords: treat album names as keywords + use_persons_as_keywords: treat person names as keywords + keyword_template: (list of strings); list of template strings to render as keywords + description_template: (list of strings); list of template strings to render for the description + ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set + + Returns: dict with exiftool tags / values + + Exports the following: + EXIF:ImageDescription + XMP:Description (may include template) + XMP:Title + XMP:TagsList + IPTC:Keywords (may include album name, person name, or template) + XMP:Subject + XMP:PersonInImage + EXIF:GPSLatitude, EXIF:GPSLongitude + EXIF:GPSPosition + EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef + EXIF:DateTimeOriginal + EXIF:OffsetTimeOriginal + EXIF:ModifyDate + IPTC:DigitalCreationDate + IPTC:DateCreated + """ + exif = self._exiftool_dict( + use_albums_as_keywords=use_albums_as_keywords, + use_persons_as_keywords=use_persons_as_keywords, + keyword_template=keyword_template, + description_template=description_template, + ignore_date_modified=ignore_date_modified, + ) + return json.dumps([exif]) def _xmp_sidecar( diff --git a/osxphotos/photoinfo/photoinfo.py b/osxphotos/photoinfo/photoinfo.py index 4a6a8639..de0829c2 100644 --- a/osxphotos/photoinfo/photoinfo.py +++ b/osxphotos/photoinfo/photoinfo.py @@ -53,6 +53,7 @@ class PhotoInfo: export, export2, _export_photo, + _exiftool_dict, _exiftool_json_sidecar, _write_exif_data, _write_sidecar, diff --git a/tests/test_cli.py b/tests/test_cli.py index 09f44f64..74360cd6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -324,6 +324,24 @@ CLI_EXIFTOOL = { } } +CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = { + "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": { + "File:FileName": "wedding.jpg", + "EXIF:ImageDescription": "Bride Wedding day", + "XMP:Description": "Bride Wedding day", + "XMP:TagsList": "wedding", + "IPTC:Keywords": "wedding", + "XMP:PersonInImage": "Maria", + "XMP:Subject": ["wedding", "Maria"], + "EXIF:DateTimeOriginal": "2019:04:15 14:40:24", + "EXIF:CreateDate": "2019:04:15 14:40:24", + "EXIF:OffsetTimeOriginal": "-04:00", + "IPTC:DigitalCreationDate": "2019:04:15", + "IPTC:DateCreated": "2019:04:15", + "EXIF:ModifyDate": "2019:04:15 14:40:24", + } +} + LABELS_JSON = { "labels": { "Plant": 7, @@ -931,6 +949,38 @@ def test_export_exiftool(): assert exif[key] == CLI_EXIFTOOL[uuid][key] +@pytest.mark.skipif(exiftool is None, reason="exiftool not installed") +def test_export_exiftool_ignore_date_modified(): + import glob + import os + import os.path + from osxphotos.__main__ import export + from osxphotos.exiftool import ExifTool + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + for uuid in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED: + result = runner.invoke( + export, + [ + os.path.join(cwd, PHOTOS_DB_15_6), + ".", + "-V", + "--exiftool", + "--ignore-date-modified", + "--uuid", + f"{uuid}", + ], + ) + assert result.exit_code == 0 + + exif = ExifTool(CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"]).asdict() + for key in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]: + assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key] + + def test_export_edited_suffix(): """ test export with --edited-suffix """ import glob diff --git a/tests/test_export_catalina_10_15_7.py b/tests/test_export_catalina_10_15_7.py index e285cee0..8de901e6 100644 --- a/tests/test_export_catalina_10_15_7.py +++ b/tests/test_export_catalina_10_15_7.py @@ -83,6 +83,22 @@ EXIF_JSON_EXPECTED = """ "EXIF:ModifyDate": "2019:07:27 17:33:28"}] """ +EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """ + [{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", + "EXIF:ImageDescription": "Bride Wedding day", + "XMP:Description": "Bride Wedding day", + "XMP:TagsList": ["wedding"], + "IPTC:Keywords": ["wedding"], + "XMP:PersonInImage": ["Maria"], + "XMP:Subject": ["wedding", "Maria"], + "EXIF:DateTimeOriginal": "2019:04:15 14:40:24", + "EXIF:CreateDate": "2019:04:15 14:40:24", + "EXIF:OffsetTimeOriginal": "-04:00", + "IPTC:DigitalCreationDate": "2019:04:15", + "IPTC:DateCreated": "2019:04:15", + "EXIF:ModifyDate": "2019:04:15 14:40:24"}] + """ + def test_export_1(): # test basic export @@ -492,6 +508,32 @@ def test_exiftool_json_sidecar(): assert json_got[k] == v +def test_exiftool_json_sidecar_ignore_date_modified(): + import osxphotos + import json + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB) + photos = photosdb.photos(uuid=[EXIF_JSON_UUID]) + + json_expected = json.loads(EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED)[0] + + json_got = photos[0]._exiftool_json_sidecar(ignore_date_modified=True) + json_got = json.loads(json_got)[0] + + # some gymnastics to account for different sort order in different pythons + for k, v in json_got.items(): + if type(v) in (list, tuple): + assert sorted(json_expected[k]) == sorted(v) + else: + assert json_expected[k] == v + + for k, v in json_expected.items(): + if type(v) in (list, tuple): + assert sorted(json_got[k]) == sorted(v) + else: + assert json_got[k] == v + + def test_exiftool_json_sidecar_keyword_template_long(caplog): import osxphotos from osxphotos._constants import _MAX_IPTC_KEYWORD_LEN