Added --ignore-date-modified flag, issue #247

This commit is contained in:
Rhet Turnbull
2020-11-01 09:13:45 -08:00
parent 3660b6360a
commit 663e33bc17
7 changed files with 224 additions and 39 deletions

View File

@@ -329,6 +329,11 @@ Options:
exiftool may be installed from exiftool may be installed from
https://exiftool.org/. Cannot be used with https://exiftool.org/. Cannot be used with
--export-as-hardlink. --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 --directory DIRECTORY Optional template for specifying name of
output directory in the form output directory in the form
'{name,DEFAULT}'. See below for additional '{name,DEFAULT}'. See below for additional

View File

@@ -1290,6 +1290,13 @@ def query(
"exiftool may be installed from https://exiftool.org/. " "exiftool may be installed from https://exiftool.org/. "
"Cannot be used with --export-as-hardlink.", "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( @click.option(
"--directory", "--directory",
metavar="DIRECTORY", metavar="DIRECTORY",
@@ -1389,6 +1396,7 @@ def export(
download_missing, download_missing,
dest, dest,
exiftool, exiftool,
ignore_date_modified,
portrait, portrait,
not_portrait, not_portrait,
screenshot, screenshot,
@@ -1663,6 +1671,7 @@ def export(
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
) )
results_exported.extend(results.exported) results_exported.extend(results.exported)
results_new.extend(results.new) results_new.extend(results.new)
@@ -1712,6 +1721,7 @@ def export(
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
) )
results_exported.extend(results.exported) results_exported.extend(results.exported)
results_new.extend(results.new) results_new.extend(results.new)
@@ -2218,6 +2228,7 @@ def export_photo(
use_photos_export=False, use_photos_export=False,
convert_to_jpeg=False, convert_to_jpeg=False,
jpeg_quality=1.0, jpeg_quality=1.0,
ignore_date_modified=False,
): ):
""" Helper function for export that does the actual export """ 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 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 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. 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: Returns:
list of path(s) of exported photo or None if photo was missing list of path(s) of exported photo or None if photo was missing
@@ -2339,6 +2351,7 @@ def export_photo(
touch_file=touch_file, touch_file=touch_file,
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
) )
results_exported.extend(export_results.exported) results_exported.extend(export_results.exported)
@@ -2400,6 +2413,7 @@ def export_photo(
touch_file=touch_file, touch_file=touch_file,
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
) )
results_exported.extend(export_results_edited.exported) results_exported.extend(export_results_edited.exported)

View File

@@ -1,4 +1,4 @@
""" version info """ """ version info """
__version__ = "0.36.4" __version__ = "0.36.5"

View File

@@ -5,6 +5,7 @@
_export_photo _export_photo
_write_exif_data _write_exif_data
_exiftool_json_sidecar _exiftool_json_sidecar
_exiftool_dict
_xmp_sidecar _xmp_sidecar
_write_sidecar _write_sidecar
""" """
@@ -308,6 +309,7 @@ def export2(
touch_file=False, touch_file=False,
convert_to_jpeg=False, convert_to_jpeg=False,
jpeg_quality=1.0, jpeg_quality=1.0,
ignore_date_modified=False,
): ):
""" export photo, like export but with update and dry_run options """ export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised 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 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 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. 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 Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
where each field is a list of file paths where each field is a list of file paths
@@ -698,6 +701,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template, keyword_template=keyword_template,
description_template=description_template, description_template=description_template,
ignore_date_modified=ignore_date_modified,
) )
if not dry_run: if not dry_run:
try: try:
@@ -743,6 +747,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template, keyword_template=keyword_template,
description_template=description_template, description_template=description_template,
ignore_date_modified=ignore_date_modified,
) )
)[0] )[0]
if old_data != current_data: if old_data != current_data:
@@ -758,6 +763,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template, keyword_template=keyword_template,
description_template=description_template, description_template=description_template,
ignore_date_modified=ignore_date_modified,
) )
export_db.set_exifdata_for_file( export_db.set_exifdata_for_file(
exported_file, exported_file,
@@ -766,6 +772,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template, keyword_template=keyword_template,
description_template=description_template, description_template=description_template,
ignore_date_modified=ignore_date_modified,
), ),
) )
export_db.set_stat_exif_for_file( export_db.set_stat_exif_for_file(
@@ -781,6 +788,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template, keyword_template=keyword_template,
description_template=description_template, description_template=description_template,
ignore_date_modified=ignore_date_modified,
) )
export_db.set_exifdata_for_file( export_db.set_exifdata_for_file(
@@ -790,6 +798,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template, keyword_template=keyword_template,
description_template=description_template, description_template=description_template,
ignore_date_modified=ignore_date_modified,
), ),
) )
export_db.set_stat_exif_for_file( export_db.set_stat_exif_for_file(
@@ -997,21 +1006,30 @@ def _write_exif_data(
use_persons_as_keywords=False, use_persons_as_keywords=False,
keyword_template=None, keyword_template=None,
description_template=None, description_template=None,
ignore_date_modified=False,
): ):
""" write exif data to image file at filepath """ 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): if not os.path.exists(filepath):
raise FileNotFoundError(f"Could not find file {filepath}") raise FileNotFoundError(f"Could not find file {filepath}")
exiftool = ExifTool(filepath) exiftool = ExifTool(filepath)
exif_info = json.loads( exif_info = self._exiftool_dict(
self._exiftool_json_sidecar( use_albums_as_keywords=use_albums_as_keywords,
use_albums_as_keywords=use_albums_as_keywords, use_persons_as_keywords=use_persons_as_keywords,
use_persons_as_keywords=use_persons_as_keywords, keyword_template=keyword_template,
keyword_template=keyword_template, description_template=description_template,
description_template=description_template, ignore_date_modified=ignore_date_modified,
) )
)[0]
for exiftag, val in exif_info.items(): for exiftag, val in exif_info.items():
if exiftag == "_CreatedBy":
continue
if type(val) == list: if type(val) == list:
# more than one, set first value the add additional values # more than one, set first value the add additional values
exiftool.setvalue(exiftag, val.pop(0)) exiftool.setvalue(exiftag, val.pop(0))
@@ -1022,37 +1040,46 @@ def _write_exif_data(
exiftool.setvalue(exiftag, val) exiftool.setvalue(exiftag, val)
def _exiftool_json_sidecar( def _exiftool_dict(
self, self,
use_albums_as_keywords=False, use_albums_as_keywords=False,
use_persons_as_keywords=False, use_persons_as_keywords=False,
keyword_template=None, keyword_template=None,
description_template=None, description_template=None,
ignore_date_modified=False,
): ):
""" return json string of EXIF details in exiftool sidecar format """ 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 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_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person 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 keyword_template: (list of strings); list of template strings to render as keywords
Exports the following: description_template: (list of strings); list of template strings to render for the description
FileName ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
ImageDescription
Description Returns: dict with exiftool tags / values
Title
TagsList Exports the following:
Keywords (may include album name, person name, or template) EXIF:ImageDescription
Subject XMP:Description (may include template)
PersonInImage XMP:Title
GPSLatitude, GPSLongitude XMP:TagsList
GPSPosition IPTC:Keywords (may include album name, person name, or template)
GPSLatitudeRef, GPSLongitudeRef XMP:Subject
DateTimeOriginal XMP:PersonInImage
OffsetTimeOriginal EXIF:GPSLatitude, EXIF:GPSLongitude
ModifyDate """ EXIF:GPSPosition
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal
EXIF:ModifyDate
IPTC:DigitalCreationDate
IPTC:DateCreated
"""
exif = {} exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos" exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
if description_template is not None: if description_template is not None:
description = self.render_template( description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", " description_template, expand_inplace=True, inplace_sep=", "
@@ -1114,15 +1141,16 @@ def _exiftool_json_sidecar(
keyword_list.extend(rendered_keywords) keyword_list.extend(rendered_keywords)
if keyword_list: 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: if person_list:
exif["XMP:PersonInImage"] = person_list exif["XMP:PersonInImage"] = person_list.copy()
if self.keywords or person_list: if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP" # Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
# only use Photos' keywords for subject # only use Photos' keywords for subject (e.g. don't include template values)
exif["XMP:Subject"] = list(self.keywords) + person_list exif["XMP:Subject"] = self.keywords.copy() + person_list.copy()
# if self.favorite(): # if self.favorite():
# exif["Rating"] = 5 # exif["Rating"] = 5
@@ -1144,10 +1172,10 @@ def _exiftool_json_sidecar(
# [IPTC] Digital Creation Date : 2020:10:30 # [IPTC] Digital Creation Date : 2020:10:30
# [IPTC] Date Created : 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 # if photo has modification date, use it otherwise use creation date
date = self.date date = self.date
# exiftool expects format to "2015:01:18 12:00:00" # exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S") datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
exif["EXIF:DateTimeOriginal"] = datetimeoriginal exif["EXIF:DateTimeOriginal"] = datetimeoriginal
@@ -1160,18 +1188,63 @@ def _exiftool_json_sidecar(
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}" offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["EXIF:OffsetTimeOriginal"] = offsettime exif["EXIF:OffsetTimeOriginal"] = offsettime
dateoriginal = date.strftime("%Y:%m:%d") dateoriginal = date.strftime("%Y:%m:%d")
exif["IPTC:DigitalCreationDate"] = dateoriginal exif["IPTC:DigitalCreationDate"] = dateoriginal
exif["IPTC:DateCreated"] = 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") exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
else: else:
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S") 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( def _xmp_sidecar(

View File

@@ -53,6 +53,7 @@ class PhotoInfo:
export, export,
export2, export2,
_export_photo, _export_photo,
_exiftool_dict,
_exiftool_json_sidecar, _exiftool_json_sidecar,
_write_exif_data, _write_exif_data,
_write_sidecar, _write_sidecar,

View File

@@ -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_JSON = {
"labels": { "labels": {
"Plant": 7, "Plant": 7,
@@ -931,6 +949,38 @@ def test_export_exiftool():
assert exif[key] == CLI_EXIFTOOL[uuid][key] 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(): def test_export_edited_suffix():
""" test export with --edited-suffix """ """ test export with --edited-suffix """
import glob import glob

View File

@@ -83,6 +83,22 @@ EXIF_JSON_EXPECTED = """
"EXIF:ModifyDate": "2019:07:27 17:33:28"}] "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(): def test_export_1():
# test basic export # test basic export
@@ -492,6 +508,32 @@ def test_exiftool_json_sidecar():
assert json_got[k] == v 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): def test_exiftool_json_sidecar_keyword_template_long(caplog):
import osxphotos import osxphotos
from osxphotos._constants import _MAX_IPTC_KEYWORD_LEN from osxphotos._constants import _MAX_IPTC_KEYWORD_LEN