From 4dfb131a21b1b1efefe3b918ecb06fc6fcb03f2c Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sun, 26 Jan 2020 09:34:53 -0800 Subject: [PATCH] Added XMP sidecar to export --- osxphotos/__main__.py | 27 ++++++-- osxphotos/_constants.py | 4 ++ osxphotos/photoinfo.py | 67 +++++++++++++++---- osxphotos/templates/xmp_sidecar.mako | 99 ++++++++++++++++++++++++++++ requirements.txt | 1 + setup.py | 2 +- 6 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 osxphotos/templates/xmp_sidecar.mako diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 7bdff566..ca32fc13 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -622,13 +622,17 @@ def query( ) @click.option( "--sidecar", - is_flag=True, - help="Create JSON sidecar for each photo exported " - f"in format useable by exiftool ({_EXIF_TOOL_URL}) " + default=None, + multiple=True, + metavar="FORMAT", + type=click.Choice(['xmp', 'json'], case_sensitive=False), + help="Create sidecar for each photo exported; valid FORMAT values: xmp, json; " + f"--sidecar json: create JSON sidecar useable by exiftool ({_EXIF_TOOL_URL}) " "The sidecar file can be used to apply metadata to the file with exiftool, for example: " '"exiftool -j=photoname.jpg.json photoname.jpg" ' "The sidecar file is named in format photoname.ext.json where ext is extension of the photo (e.g. jpg). " - "Note: this does not create an XMP sidecar as used by Lightroom, etc.", + "--sidecar xmp: create XMP sidecar used by Adobe Lightroom, etc." + "The sidecar file is named in format photoname.ext.xmp where ext is extension of the photo (e.g. jpg). " ) @click.option( "--download-missing", @@ -1060,7 +1064,7 @@ def export_photo( dest: destination path as string verbose: boolean; print verbose output export_by_date: boolean; create export folder in form dest/YYYY/MM/DD - sidecar: boolean; create json sidecar file with export + sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export overwrite: boolean; overwrite dest file if it already exists original_name: boolean; use original filename instead of current filename export_live: boolean; also export live video component if photo is a live photo @@ -1100,10 +1104,18 @@ def export_photo( date_created = photo.date.timetuple() dest = create_path_by_date(dest, date_created) + sidecar = [s.lower() for s in sidecar] + sidecar_json = sidecar_xmp = False + if "json" in sidecar: + sidecar_json = True + if "xmp" in sidecar: + sidecar_xmp = True + photo_path = photo.export( dest, filename, - sidecar=sidecar, + sidecar_json=sidecar_json, + sidecar_xmp=sidecar_xmp, overwrite=overwrite, use_photos_export=download_missing, ) @@ -1119,7 +1131,8 @@ def export_photo( photo.export( dest, edited_name, - sidecar=sidecar, + sidecar_json=sidecar_json, + sidecar_xmp=sidecar_xmp, overwrite=overwrite, edited=True, use_photos_export=download_missing, diff --git a/osxphotos/_constants.py b/osxphotos/_constants.py index c9c5d82d..98d3b42c 100644 --- a/osxphotos/_constants.py +++ b/osxphotos/_constants.py @@ -2,6 +2,7 @@ Constants used by osxphotos """ +import os.path # which Photos library database versions have been tested # Photos 2.0 (10.12.6) == 2622 @@ -30,3 +31,6 @@ _PHOTOS_5_SHARED_PHOTO_PATH = "resources/cloudsharing/data" _PHOTO_TYPE = 0 _MOVIE_TYPE = 1 +# Name of XMP template file +_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates") +_XMP_TEMPLATE_NAME = "xmp_sidecar.mako" diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 4dca2604..4303fd83 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -15,18 +15,21 @@ from datetime import timedelta, timezone from pprint import pformat import yaml +from mako.template import Template from ._constants import ( _MOVIE_TYPE, _PHOTO_TYPE, _PHOTOS_5_SHARED_PHOTO_PATH, _PHOTOS_5_VERSION, + _TEMPLATE_DIR, + _XMP_TEMPLATE_NAME, ) from .utils import ( _copy_file, + _export_photo_uuid_applescript, _get_resource_loc, dd_to_dms_str, - _export_photo_uuid_applescript, ) # TODO: check pylint output @@ -458,7 +461,8 @@ class PhotoInfo: edited=False, overwrite=False, increment=True, - sidecar=False, + sidecar_json=False, + sidecar_xmp=False, use_photos_export=False, timeout=120, ): @@ -470,8 +474,10 @@ class PhotoInfo: overwrite: (boolean, default=False); if True will overwrite files if they alreay exist increment: (boolean, default=True); if True, will increment file name until a non-existant name is found if overwrite=False and increment=False, export will fail if destination file already exists - sidecar: (boolean, default = False); if True will also write a json sidecar with EXIF data in format readable by exiftool + sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool sidecar filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg) + sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data + sidecar filename will be dest/filename.ext.xmp where ext is suffix of the image file (e.g. jpeg or jpg) use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos timeout: (int, default=120) timeout in seconds used with use_photos_export returns the full path to the exported file """ @@ -589,20 +595,47 @@ class PhotoInfo: if exported is None: logging.warning(f"Error exporting photo {self.uuid} to {dest}") - if sidecar: + if sidecar_json: logging.debug("writing exiftool_json_sidecar") sidecar_filename = f"{dest}.json" - json_sidecar_str = self._exiftool_json_sidecar() + sidecar_str = self._exiftool_json_sidecar() try: - self._write_sidecar_car(sidecar_filename, json_sidecar_str) + self._write_sidecar(sidecar_filename, sidecar_str) except Exception as e: - logging.critical(f"Error writing json sidecar to {sidecar_filename}") + logging.warning(f"Error writing json sidecar to {sidecar_filename}") + raise e + + if sidecar_xmp: + logging.debug("writing xmp_sidecar") + sidecar_filename = f"{dest}.xmp" + sidecar_str = self._xmp_sidecar() + try: + self._write_sidecar(sidecar_filename, sidecar_str) + except Exception as e: + logging.warning(f"Error writing xmp sidecar to {sidecar_filename}") raise e return str(dest) def _exiftool_json_sidecar(self): - """ return json string of EXIF details in exiftool sidecar format """ + """ 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 + Exports the following: + FileName + ImageDescription + Description + Title + TagsList + Keywords + Subject + PersonInImage + GPSLatitude, GPSLongitude + GPSPosition + GPSLatitudeRef, GPSLongitudeRef + DateTimeOriginal + OffsetTimeOriginal + ModifyDate """ + exif = {} exif["FileName"] = self.filename @@ -658,17 +691,27 @@ class PhotoInfo: json_str = json.dumps([exif]) return json_str - def _write_sidecar_car(self, filename, json_str): - if not filename and not json_str: + def _xmp_sidecar(self): + """ returns string for XMP sidecar """ + xmp_template = Template( + filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME) + ) + xmp_str = xmp_template.render(photo=self) + return xmp_str + + def _write_sidecar(self, filename, sidecar_str): + """ write sidecar_str to filename + used for exporting sidecar info """ + if not filename and not sidecar_str: raise ( ValueError( - f"filename {filename} and json_str {json_str} must not be None" + f"filename {filename} and sidecar_str {sidecar_str} must not be None" ) ) # TODO: catch exception? f = open(filename, "w") - f.write(json_str) + f.write(sidecar_str) f.close() @property diff --git a/osxphotos/templates/xmp_sidecar.mako b/osxphotos/templates/xmp_sidecar.mako new file mode 100644 index 00000000..1f2e1a8c --- /dev/null +++ b/osxphotos/templates/xmp_sidecar.mako @@ -0,0 +1,99 @@ + + +<%def name="dc_description(desc)"> + % if desc is None: + + % else: + ${desc} + % endif + + +<%def name="dc_title(title)"> + % if title is None: + + % else: + ${title} + % endif + + +<%def name="dc_subject(subject)"> + % if subject: + + + + % for subj in subject: + ${subj} + % endfor + + + % endif + + +<%def name="dc_datecreated(date)"> + % if date is not None: + ${date.isoformat()} + % endif + + +<%def name="iptc_personinimage(persons)"> + % if persons: + + + % for person in persons: + ${person} + % endfor + + + % endif + + +<%def name="dk_tagslist(keywords)"> + % if keywords: + + + % for keyword in keywords: + ${keyword} + % endfor + + + % endif + + +<%def name="adobe_createdate(date)"> + % if date is not None: + ${date.strftime("%Y-%m-%dT%H:%M:%S")} + % endif + + +<%def name="adobe_modifydate(date)"> + % if date is not None: + ${date.strftime("%Y-%m-%dT%H:%M:%S")} + % endif + + + + + + + ${dc_description(photo.description)} + ${dc_title(photo.title)} + ${dc_subject(photo.keywords + photo.persons)} + ${dc_datecreated(photo.date)} + + + ${iptc_personinimage(photo.persons)} + + + ${dk_tagslist(photo.keywords)} + + + ${adobe_createdate(photo.date)} + ${adobe_modifydate(photo.date)} + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6a7c274d..29f42540 100644 --- a/requirements.txt +++ b/requirements.txt @@ -143,3 +143,4 @@ termcolor==1.1.0 wcwidth==0.1.7 wrapt==1.11.1 zipp==0.5.2 +Mako==1.1.1 \ No newline at end of file diff --git a/setup.py b/setup.py index ec53f412..ea96fc01 100755 --- a/setup.py +++ b/setup.py @@ -61,6 +61,6 @@ setup( "Programming Language :: Python :: 3.6", "Topic :: Software Development :: Libraries :: Python Modules", ], - install_requires=["pyobjc>=6.0.1", "Click>=7", "PyYAML>=5.1.2"], + install_requires=["pyobjc>=6.0.1", "Click>=7", "PyYAML>=5.1.2", "Mako>=1.1.1"], entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]}, )