Compare commits

...

7 Commits

Author SHA1 Message Date
Rhet Turnbull
541c390b7b Added height, width, orientation, filesize, closes #163 2020-07-03 11:24:59 -07:00
Rhet Turnbull
6ab0ad7e86 Added GPS location to XMP sidecar, closes #175 2020-07-03 09:04:23 -07:00
Rhet Turnbull
e5755c6144 Updated CHANGELOG.md 2020-06-28 21:54:36 -07:00
Rhet Turnbull
7806e05673 Updated README.md 2020-06-28 21:53:50 -07:00
Rhet Turnbull
bb4bc8fd96 Added --description-template to CLI, closes #166 2020-06-28 20:10:38 -07:00
Rhet Turnbull
59507077ba Updated README.md 2020-06-28 13:50:12 -07:00
Rhet Turnbull
ff0328785f Added expand_inplace to PhotoTemplate.render 2020-06-28 13:46:35 -07:00
16 changed files with 567 additions and 48 deletions

View File

@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.30.3](https://github.com/RhetTbull/osxphotos/compare/v0.30.2...v0.30.3)
> 29 June 2020
- Added --description-template to CLI, closes #166 [`#166`](https://github.com/RhetTbull/osxphotos/issues/166)
- Added expand_inplace to PhotoTemplate.render [`ff03287`](https://github.com/RhetTbull/osxphotos/commit/ff0328785f3ea14b1c8ae2b7d1a9b07e8aef0777)
- Updated README.md [`5950707`](https://github.com/RhetTbull/osxphotos/commit/59507077bafe39a17bc23babe6d6c52e1f502a53)
#### [v0.30.2](https://github.com/RhetTbull/osxphotos/compare/v0.30.1...v0.30.2)
> 28 June 2020
- Added --deleted, --deleted-only to CLI, closes #179 [`#179`](https://github.com/RhetTbull/osxphotos/issues/179)
#### [v0.30.1](https://github.com/RhetTbull/osxphotos/compare/v0.30.0...v0.30.1)
> 27 June 2020

View File

@@ -253,6 +253,16 @@ Options:
--keyword-template "{folder_album}"
--keyword-template "{created.year}" See
Templating System below.
--description-template TEMPLATE
For use with --exiftool, --sidecar; specify
a template string to use as description in
the form '{name,DEFAULT}' This is the same
format as --directory. For example, if you
wanted to append 'exported with osxphotos on
[today's date]' to the description, you
could specify --description-template
"{descr} exported with osxphotos on
{today.date}" See Templating System below.
--current-name Use photo's current filename instead of
original filename for export. Note:
Starting with Photos 5, all photos are
@@ -1038,6 +1048,27 @@ Returns the absolute path to the edited photo on disk as a string. If the photo
**Note**: will also return None if the edited photo is missing on disk.
#### `height`
Returns height of the photo in pixels. If image has been edited, returns height of the edited image, otherwise returns height of the original image. See also [original_height](#original_height).
#### `width`
Returns width of the photo in pixels. If image has been edited, returns width of the edited image, otherwise returns width of the original image. See also [original_width](#original_width).
#### `orientation`
Returns EXIF orientation value of the photo as integer. If image has been edited, returns orientation of the edited image, otherwise returns orientation of the original image. See also [original_orientation](#original_orientation).
#### `original_height`
Returns height of the original photo in pixels. See also [height](#height).
#### `original_width`
Returns width of the original photo in pixels. See also [width](#width).
#### `original_orientation`
Returns EXIF orientation value of the original photo as integer. See also [orientation](#orientation).
#### `original_filesize`
Returns size of the original photo in bytes as integer.
#### `ismissing`
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted and user hasn't enabled "Copy items to the Photos library" in Photos preferences. **Note**: this status is computed based on data in the Photos library and `ismissing` does not verify if the photo is actually missing. See also [path](#path).
@@ -1256,11 +1287,13 @@ If overwrite=False and increment=False, export will fail if destination file alr
#### <a name="rendertemplate">`render_template()`</a>
`render_template(template_str, none_str = "_", path_sep = None)`
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None)`
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
- `template_str`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
- `path_sep`: optional character to use as path separator, default is os.path.sep
- `expand_inplace`: expand multi-valued substitutions in-place as a single string instead of returning individual strings
- `inplace_sep`: optional string to use as separator between multi-valued keywords with expand_inplace; default is ','
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].

View File

@@ -1146,6 +1146,18 @@ def query(
'--keyword-template "{created.year}" '
"See Templating System below.",
)
@click.option(
"--description-template",
metavar="TEMPLATE",
multiple=False,
default=None,
help="For use with --exiftool, --sidecar; specify a template string to use as "
"description in the form '{name,DEFAULT}' "
"This is the same format as --directory. For example, if you wanted to append "
"'exported with osxphotos on [today's date]' to the description, you could specify "
'--description-template "{descr} exported with osxphotos on {today.date}" '
"See Templating System below.",
)
@click.option(
"--current-name",
is_flag=True,
@@ -1260,6 +1272,7 @@ def export(
person_keyword,
album_keyword,
keyword_template,
description_template,
current_name,
sidecar,
only_photos,
@@ -1505,6 +1518,7 @@ def export(
album_keyword=album_keyword,
person_keyword=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
@@ -1541,6 +1555,7 @@ def export(
album_keyword=album_keyword,
person_keyword=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
@@ -2012,6 +2027,7 @@ def export_photo(
album_keyword=None,
person_keyword=None,
keyword_template=None,
description_template=None,
export_db=None,
fileutil=FileUtil,
dry_run=None,
@@ -2039,6 +2055,7 @@ def export_photo(
album_keyword: boolean; if True, exports album names as keywords in metadata
person_keyword: boolean; if True, exports person names as keywords in metadata
keyword_template: list of strings; if provided use rendered template strings as keywords
description_template: string; optional template string that will be rendered for use as photo description
export_db: export database instance compatible with ExportDB_ABC
fileutil: file util class compatible with FileUtilABC
dry_run: boolean; if True, doesn't actually export or update any files
@@ -2113,6 +2130,7 @@ def export_photo(
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
update=update,
export_db=export_db,
fileutil=fileutil,
@@ -2168,6 +2186,7 @@ def export_photo(
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
update=update,
export_db=export_db,
fileutil=fileutil,

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.30.2"
__version__ = "0.30.5"

View File

@@ -215,6 +215,7 @@ def export(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" export photo
dest: must be valid destination path (or exception raised)
@@ -250,6 +251,7 @@ def export(
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
returns: list of photos exported
"""
@@ -273,6 +275,7 @@ def export(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
return results.exported
@@ -297,6 +300,7 @@ def export2(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
update=False,
export_db=None,
fileutil=FileUtil,
@@ -336,6 +340,7 @@ def export2(
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
update: (boolean, default=False); if True export will run in update mode, that is, it will
not export the photo if the current version already exists in the destination
export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
@@ -670,6 +675,7 @@ def export2(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
if not dry_run:
try:
@@ -685,6 +691,7 @@ def export2(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
if not dry_run:
try:
@@ -712,6 +719,7 @@ def export2(
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]
if old_data != current_data:
@@ -727,6 +735,7 @@ def export2(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
export_db.set_exifdata_for_file(
exported_file,
@@ -734,6 +743,7 @@ def export2(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
),
)
export_db.set_stat_exif_for_file(
@@ -749,6 +759,7 @@ def export2(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
export_db.set_exifdata_for_file(
exported_file,
@@ -756,6 +767,7 @@ def export2(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
),
)
export_db.set_stat_exif_for_file(
@@ -955,6 +967,7 @@ def _write_exif_data(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" write exif data to image file at filepath
filepath: full path to the image file """
@@ -966,6 +979,7 @@ def _write_exif_data(
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]
for exiftag, val in exif_info.items():
@@ -984,6 +998,7 @@ def _exiftool_json_sidecar(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" 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
@@ -1009,7 +1024,13 @@ def _exiftool_json_sidecar(
exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
if self.description:
if description_template is not None:
description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
exif["EXIF:ImageDescription"] = description
exif["XMP:Description"] = description
elif self.description:
exif["EXIF:ImageDescription"] = self.description
exif["XMP:Description"] = self.description
@@ -1082,7 +1103,6 @@ def _exiftool_json_sidecar(
lat_str, lon_str = dd_to_dms_str(lat, lon)
exif["EXIF:GPSLatitude"] = lat_str
exif["EXIF:GPSLongitude"] = lon_str
exif["Composite:GPSPosition"] = f"{lat_str}, {lon_str}"
lat_ref = "North" if lat >= 0 else "South"
lon_ref = "East" if lon >= 0 else "West"
exif["EXIF:GPSLatitudeRef"] = lat_ref
@@ -1112,16 +1132,25 @@ def _xmp_sidecar(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" returns string for XMP sidecar
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 """
keyword_template: (list of strings); list of template strings to render as keywords
description_template: string; optional template string that will be rendered for use as photo description """
# TODO: add additional fields to XMP file?
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
if description_template is not None:
description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
else:
description = self.description if self.description is not None else ""
keyword_list = []
if self.keywords:
keyword_list.extend(self.keywords)
@@ -1178,7 +1207,11 @@ def _xmp_sidecar(
subject_list = list(self.keywords) + person_list
xmp_str = xmp_template.render(
photo=self, keywords=keyword_list, persons=person_list, subjects=subject_list
photo=self,
description=description,
keywords=keyword_list,
persons=person_list,
subjects=subject_list,
)
# remove extra lines that mako inserts from template

View File

@@ -642,7 +642,49 @@ class PhotoInfo:
otherwise returns False """
return self._info["raw_is_original"]
def render_template(self, template_str, none_str="_", path_sep=None):
@property
def height(self):
""" returns height of the current photo version in pixels """
return self._info["height"]
@property
def width(self):
""" returns width of the current photo version in pixels """
return self._info["width"]
@property
def orientation(self):
""" returns EXIF orientation of the current photo version as int """
return self._info["orientation"]
@property
def original_height(self):
""" returns height of the original photo version in pixels """
return self._info["original_height"]
@property
def original_width(self):
""" returns width of the original photo version in pixels """
return self._info["original_width"]
@property
def original_orientation(self):
""" returns EXIF orientation of the original photo version as int """
return self._info["original_orientation"]
@property
def original_filesize(self):
""" returns filesize of original photo in bytes as int """
return self._info["original_filesize"]
def render_template(
self,
template_str,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
):
"""Renders a template string for PhotoInfo instance using PhotoTemplate
Args:
@@ -650,12 +692,22 @@ class PhotoInfo:
none_str: a str to use if template field renders to None, default is "_".
path_sep: a single character str to use as path separator when joining
fields like folder_album; if not provided, defaults to os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
template = PhotoTemplate(self)
return template.render(template_str, none_str, path_sep)
return template.render(
template_str,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
)
@property
def _longitude(self):

View File

@@ -716,7 +716,14 @@ class PhotosDB:
RKVersion.rawMasterUuid,
RKVersion.nonRawMasterUuid,
RKMaster.alternateMasterUuid,
RKVersion.isInTrash
RKVersion.isInTrash,
RKVersion.processedHeight,
RKVersion.processedWidth,
RKVersion.orientation,
RKMaster.height,
RKMaster.width,
RKMaster.orientation,
RKMaster.fileSize
FROM RKVersion, RKMaster
WHERE RKVersion.masterUuid = RKMaster.uuid"""
)
@@ -736,7 +743,14 @@ class PhotosDB:
RKVersion.rawMasterUuid,
RKVersion.nonRawMasterUuid,
RKMaster.alternateMasterUuid,
RKVersion.isInTrash
RKVersion.isInTrash,
RKVersion.processedHeight,
RKVersion.processedWidth,
RKVersion.orientation,
RKMaster.height,
RKMaster.width,
RKMaster.orientation,
RKMaster.originalFileSize
FROM RKVersion, RKMaster
WHERE RKVersion.masterUuid = RKMaster.uuid"""
)
@@ -775,6 +789,13 @@ class PhotosDB:
# 30 RKVersion.nonRawMasterUuid, -- UUID of non-RAW master
# 31 RKMaster.alternateMasterUuid -- UUID of alternate master (will be RAW master for JPEG and JPEG master for RAW)
# 32 RKVersion.isInTrash
# 33 RKVersion.processedHeight,
# 34 RKVersion.processedWidth,
# 35 RKVersion.orientation,
# 36 RKMaster.height,
# 37 RKMaster.width,
# 38 RKMaster.orientation,
# 39 RKMaster.originalFileSize
for row in c:
uuid = row[0]
@@ -920,6 +941,15 @@ class PhotosDB:
# recently deleted items
self._dbphotos[uuid]["intrash"] = True if row[32] == 1 else False
# height/width/orientation
self._dbphotos[uuid]["height"] = row[33]
self._dbphotos[uuid]["width"] = row[34]
self._dbphotos[uuid]["orientation"] = row[35]
self._dbphotos[uuid]["original_height"] = row[36]
self._dbphotos[uuid]["original_width"] = row[37]
self._dbphotos[uuid]["original_orientation"] = row[38]
self._dbphotos[uuid]["original_filesize"] = row[39]
# get additional details from RKMaster, needed for RAW processing
c.execute(
""" SELECT
@@ -1457,7 +1487,14 @@ class PhotosDB:
ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA,
ZGENERICASSET.ZMOMENT,
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE,
ZGENERICASSET.ZTRASHEDSTATE
ZGENERICASSET.ZTRASHEDSTATE,
ZGENERICASSET.ZHEIGHT,
ZGENERICASSET.ZWIDTH,
ZGENERICASSET.ZORIENTATION,
ZADDITIONALASSETATTRIBUTES.ZORIGINALHEIGHT,
ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
FROM ZGENERICASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
ORDER BY ZGENERICASSET.ZUUID """
@@ -1493,6 +1530,13 @@ class PhotosDB:
# 26 ZGENERICASSET.ZMOMENT -- FK for ZMOMENT.Z_PK
# 27 ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE -- 1 if associated RAW image is original else 0
# 28 ZGENERICASSET.ZTRASHEDSTATE -- 0 if not in trash, 1 if in trash
# 29 ZGENERICASSET.ZHEIGHT,
# 30 ZGENERICASSET.ZWIDTH,
# 31 ZGENERICASSET.ZORIENTATION,
# 32 ZADDITIONALASSETATTRIBUTES.ZORIGINALHEIGHT,
# 33 ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
# 34 ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
# 35 ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
for row in c:
uuid = row[0]
@@ -1644,6 +1688,15 @@ class PhotosDB:
# recently deleted items
info["intrash"] = True if row[28] == 1 else False
# height/width/orientation
info["height"] = row[29]
info["width"] = row[30]
info["orientation"] = row[31]
info["original_height"] = row[32]
info["original_width"] = row[33]
info["original_orientation"] = row[34]
info["original_filesize"] = row[35]
# associated RAW image info
# will be filled in later
info["has_raw"] = False
@@ -2232,8 +2285,7 @@ class PhotosDB:
logging.debug(f"Could not find person '{person}' in database")
photos_sets.append(person_set)
# sourcery off
if from_date or to_date:
if from_date or to_date: # sourcery off
dsel = self._dbphotos
if from_date:
dsel = {

View File

@@ -124,13 +124,24 @@ class PhotoTemplate:
# gets initialized in get_template_value
self.today = None
def render(self, template, none_str="_", path_sep=None):
def render(
self,
template,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
):
""" Render a filename or directory template
Args:
template: str template
none_str: str to use default for None values, default is '_'
path_sep: optional character to use as path separator, default is os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
@@ -141,6 +152,9 @@ class PhotoTemplate:
elif path_sep is not None and len(path_sep) != 1:
raise ValueError(f"path_sep must be single character: {path_sep}")
if inplace_sep is None:
inplace_sep = ","
# the rendering happens in two phases:
# phase 1: handle all the single-value template substitutions
# results in a single string with all the template fields replaced
@@ -226,13 +240,19 @@ class PhotoTemplate:
for str_template in rendered_strings:
if regex_multi.search(str_template):
values = self.get_template_value_multi(field, path_sep)
for val in values:
if expand_inplace:
# instead of returning multiple strings, join values into a single string
val = (
inplace_sep.join(sorted(values))
if values and values[0]
else None
)
def lookup_template_value_multi(lookup_value, default):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
default is not used but required so signature matches get_template_value """
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
default is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
@@ -242,10 +262,33 @@ class PhotoTemplate:
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings.add(new_string)
# update rendered_strings for the next field to process
rendered_strings = new_strings
# update rendered_strings for the next field to process
rendered_strings = {new_string}
else:
# create a new template string for each value
for val in values:
def lookup_template_value_multi(lookup_value, default):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
default is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = make_subst_function(
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings.add(new_string)
# update rendered_strings for the next field to process
rendered_strings = new_strings
# find any {fields} that weren't replaced
unmatched = []

View File

@@ -71,29 +71,42 @@
% endif
</%def>
<%def name="gps_info(latitude, longitude)">
% if latitude is not None and longitude is not None:
<exif:GPSLongitudeRef>${"E" if longitude >= 0 else "W"}</exif:GPSLongitudeRef>
<exif:GPSLongitude>${abs(longitude)}</exif:GPSLongitude>
<exif:GPSLatitude>${abs(latitude)}</exif:GPSLatitude>
<exif:GPSLatitudeRef>${"N" if latitude >= 0 else "S"}</exif:GPSLatitudeRef>
% endif
</%def>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
${dc_description(photo.description)}
${dc_description(description)}
${dc_title(photo.title)}
${dc_subject(subjects)}
${dc_datecreated(photo.date)}
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
${iptc_personinimage(persons)}
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
${dk_tagslist(keywords)}
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
${adobe_createdate(photo.date)}
${adobe_modifydate(photo.date)}
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
${gps_info(*photo.location)}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>

View File

@@ -241,6 +241,46 @@ def test_attributes():
assert p.ismissing == False
def test_attributes_2():
""" Test attributes including height, width, etc """
import datetime
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
assert len(photos) == 1
p = photos[0]
assert p.keywords == ["wedding"]
assert p.original_filename == "wedding.jpg"
assert p.filename == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg"
assert p.date == datetime.datetime(
2019,
4,
15,
14,
40,
24,
86000,
datetime.timezone(datetime.timedelta(seconds=-14400)),
)
assert p.description == "Bride Wedding day"
assert p.title is None
assert sorted(p.albums) == ["AlbumInFolder", "I have a deleted twin"]
assert p.persons == ["Maria"]
assert p.path.endswith(
"tests/Test-10.15.5.photoslibrary/originals/E/E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg"
)
assert not p.ismissing
assert p.hasadjustments
assert p.height == 1325
assert p.width == 1526
assert p.original_height == 1367
assert p.original_width == 2048
assert p.orientation == 1
assert p.original_orientation == 1
assert p.original_filesize == 460483
def test_missing():
import osxphotos

View File

@@ -197,7 +197,13 @@ CLI_EXPORT_RAW_EDITED = [
]
CLI_EXPORT_RAW_EDITED_ORIGINAL = ["IMG_0476_2.CR2", "IMG_0476_2_edited.jpeg"]
CLI_UUID_DICT_15_5 = {"intrash": "71E3E212-00EB-430D-8A63-5E294B268554"}
CLI_UUID_DICT_15_5 = {
"intrash": "71E3E212-00EB-430D-8A63-5E294B268554",
"template": "F12384F6-CD17-4151-ACBA-AE0E3688539E",
}
CLI_TEMPLATE_SIDECAR_FILENAME = "Pumkins1.json"
CLI_UUID_DICT_14_6 = {"intrash": "3tljdX43R8+k6peNHVrJNQ"}
PHOTOS_NOT_IN_TRASH_LEN_14_6 = 7
@@ -1095,6 +1101,48 @@ def test_export_sidecar():
assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES)
def test_export_sidecar_templates():
import json
import os
import os.path
import osxphotos
from osxphotos.__main__ import cli
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, PHOTOS_DB_15_5),
".",
"--sidecar=json",
f"--uuid={CLI_UUID_DICT_15_5['template']}",
"-V",
"--keyword-template",
"{person}",
"--description-template",
"{descr} {person} {keyword} {album}",
],
)
assert result.exit_code == 0
assert os.path.isfile(CLI_TEMPLATE_SIDECAR_FILENAME)
with open(CLI_TEMPLATE_SIDECAR_FILENAME, "r") as jsonfile:
exifdata = json.load(jsonfile)
assert (
exifdata[0]["XMP:Description"][0]
== "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Test Album"
)
assert (
exifdata[0]["EXIF:ImageDescription"][0]
== "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Test Album"
)
def test_export_live():
import glob
import os

View File

@@ -455,7 +455,6 @@ def test_exiftool_json_sidecar():
"XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
"EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
"Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
"EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
"EXIF:OffsetTimeOriginal": "-04:00",
@@ -586,7 +585,7 @@ def test_xmp_sidecar():
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
@@ -595,7 +594,7 @@ def test_xmp_sidecar():
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
@@ -603,10 +602,13 @@ def test_xmp_sidecar():
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
@@ -647,7 +649,7 @@ def test_xmp_sidecar_use_persons_keyword():
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
@@ -656,7 +658,7 @@ def test_xmp_sidecar_use_persons_keyword():
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
@@ -666,11 +668,14 @@ def test_xmp_sidecar_use_persons_keyword():
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
@@ -710,7 +715,7 @@ def test_xmp_sidecar_use_albums_keyword():
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
@@ -719,7 +724,7 @@ def test_xmp_sidecar_use_albums_keyword():
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
@@ -729,11 +734,14 @@ def test_xmp_sidecar_use_albums_keyword():
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
@@ -746,3 +754,75 @@ def test_xmp_sidecar_use_albums_keyword():
sorted(xmp_expected_lines), sorted(xmp_got_lines)
):
assert line_expected == line_got
def test_xmp_sidecar_gps():
""" Test export XMP sidecar with GPS info """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<dc:description></dc:description>
<dc:title>St. James's Park</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>UK</rdf:li>
<rdf:li>England</rdf:li>
<rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-10-13T09:18:12.501000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
<rdf:li>UK</rdf:li>
<rdf:li>England</rdf:li>
<rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-10-13T09:18:12</xmp:CreateDate>
<xmp:ModifyDate>2018-10-13T09:18:12</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
<exif:GPSLongitudeRef>W</exif:GPSLongitudeRef>
<exif:GPSLongitude>0.1318055</exif:GPSLongitude>
<exif:GPSLatitude>51.50357167</exif:GPSLatitude>
<exif:GPSLatitudeRef>N</exif:GPSLatitudeRef>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar()
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
for line_expected, line_got in zip(
sorted(xmp_expected_lines), sorted(xmp_got_lines)
):
assert line_expected == line_got

View File

@@ -178,7 +178,7 @@ def test_xmp_sidecar_keyword_template():
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
@@ -187,7 +187,7 @@ def test_xmp_sidecar_keyword_template():
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
@@ -198,11 +198,14 @@ def test_xmp_sidecar_keyword_template():
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""

View File

@@ -380,7 +380,6 @@ def test_exiftool_json_sidecar():
"XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
"EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
"EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
"Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
"EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
"EXIF:OffsetTimeOriginal": "-04:00",
@@ -431,7 +430,7 @@ def test_xmp_sidecar():
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
@@ -440,7 +439,7 @@ def test_xmp_sidecar():
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
@@ -448,11 +447,14 @@ def test_xmp_sidecar():
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
@@ -490,7 +492,7 @@ def test_xmp_sidecar_keyword_template():
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
@@ -499,7 +501,7 @@ def test_xmp_sidecar_keyword_template():
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
@@ -510,11 +512,14 @@ def test_xmp_sidecar_keyword_template():
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
<rdf:Description rdf:about=""
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
<rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""

View File

@@ -44,6 +44,7 @@ UUID_DICT = {
"date_invalid": "YZFCPY24TUySvpu7owiqxA",
"intrash": "3tljdX43R8+k6peNHVrJNQ",
"not_intrash": "6bxcNnzRQKGnK4uPrCJ9UQ",
"has_adjustments": "6bxcNnzRQKGnK4uPrCJ9UQ",
}
PHOTOS_DB_LEN = 8
@@ -161,6 +162,44 @@ def test_attributes():
assert p.ismissing == False
def test_attributes_2():
""" Test attributes including height, width, etc """
import datetime
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
assert len(photos) == 1
p = photos[0]
assert p.keywords == ["wedding"]
assert p.original_filename == "wedding.jpg"
assert p.filename == "wedding.jpg"
assert p.date == datetime.datetime(
2019,
4,
15,
14,
40,
24,
86000,
datetime.timezone(datetime.timedelta(seconds=-14400)),
)
assert p.description == "Bride Wedding day"
assert p.title is None
assert sorted(p.albums) == []
assert p.persons == ["Maria"]
assert p.path.endswith("Masters/2019/07/27/20190727-131650/wedding.jpg")
assert not p.ismissing
assert p.hasadjustments
assert p.height == 1325
assert p.width == 1526
assert p.original_height == 1367
assert p.original_width == 2048
assert p.orientation == 1
assert p.original_orientation == 1
assert p.original_filesize == 460483
def test_missing():
import osxphotos
@@ -168,8 +207,8 @@ def test_missing():
photos = photosdb.photos(uuid=["od0fmC7NQx+ayVr+%i06XA"])
assert len(photos) == 1
p = photos[0]
assert p.path == None
assert p.ismissing == True
assert p.path is None
assert p.ismissing
def test_favorite():

View File

@@ -458,3 +458,48 @@ def test_subst_strftime():
rendered, unmatched = photo.render_template("{created.strftime}")
assert rendered[0] == "_"
def test_subst_expand_inplace_1():
""" Test that substitutions are correct when expand_inplace=True """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
template = "{person}"
expected = ["Katie,Suzy"]
rendered, unknown = photo.render_template(template, expand_inplace=True)
assert sorted(rendered) == sorted(expected)
def test_subst_expand_inplace_2():
""" Test that substitutions are correct when expand_inplace=True """
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
template = "{person}-{keyword}"
expected = ["Katie,Suzy-Kids"]
rendered, unknown = photo.render_template(template, expand_inplace=True)
assert sorted(rendered) == sorted(expected)
def test_subst_expand_inplace_3():
""" Test that substitutions are correct when expand_inplace=True and inplace_sep specified"""
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
template = "{person}-{keyword}"
expected = ["Katie; Suzy-Kids"]
rendered, unknown = photo.render_template(
template, expand_inplace=True, inplace_sep="; "
)
assert sorted(rendered) == sorted(expected)