diff --git a/README.md b/README.md index d596abb1..b7c5dc36 100644 --- a/README.md +++ b/README.md @@ -636,18 +636,26 @@ exported, one to each directory. For example: --directory of the following directories if the photos were created in 2019 and were in albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family -Substitution Description -{album} Album(s) photo is contained in -{folder_album} Folder path + album photo is contained in. e.g. - 'Folder/Subfolder/Album' or just 'Album' if no enclosing - folder -{keyword} Keyword(s) assigned to photo -{person} Person(s) / face(s) in a photo -{label} Image categorization label associated with a photo - (Photos 5 only) -{label_normalized} All lower case version of 'label' (Photos 5 only) -{comment} Comment(s) on shared Photos; format is 'Person name: - comment text' (Photos 5 only) +Substitution Description +{album} Album(s) photo is contained in +{folder_album} Folder path + album photo is contained in. e.g. + 'Folder/Subfolder/Album' or just 'Album' if no + enclosing folder +{keyword} Keyword(s) assigned to photo +{person} Person(s) / face(s) in a photo +{label} Image categorization label associated with a photo + (Photos 5 only) +{label_normalized} All lower case version of 'label' (Photos 5 only) +{comment} Comment(s) on shared Photos; format is 'Person + name: comment text' (Photos 5 only) +{exiftool:GROUP:TAGNAME} Use exiftool (https://exiftool.org) to extract + metadata, in form GROUP:TAGNAME, from image. E.g. + '{exiftool:EXIF:Make}' to get camera make, or + {exiftool:IPTC:Keywords} to extract keywords. See + https://exiftool.org/TagNames/ for list of valid + tag names. You must specify group (e.g. EXIF, + IPTC, etc) as used in `exiftool -G`. exiftool must + be installed in the path to use this template. ``` Example: export all photos to ~/Desktop/export group in folders by date created diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 02953066..ada88bec 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,4 +1,4 @@ """ version info """ -__version__ = "0.36.25" +__version__ = "0.37.0" diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index 3b3c58ce..8eb3c341 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -18,6 +18,7 @@ from functools import partial from ._constants import _UNKNOWN_PERSON from .datetime_formatter import DateTimeFormatter +from .exiftool import ExifTool from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart # ensure locale set to user's locale @@ -126,6 +127,10 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = { "{label}": "Image categorization label associated with a photo (Photos 5 only)", "{label_normalized}": "All lower case version of 'label' (Photos 5 only)", "{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)", + "{exiftool:GROUP:TAGNAME}": "Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. " + "E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. " + "See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) " + "as used in `exiftool -G`. exiftool must be installed in the path to use this template.", } # Just the multi-valued substitution names without the braces @@ -150,6 +155,62 @@ class PhotoTemplate: # gets initialized in get_template_value self.today = None + def make_subst_function( + self, none_str, filename, dirname, replacement, get_func=None + ): + """ returns: substitution function for use in re.sub + none_str: value to use if substitution lookup is None and no default provided + get_func: function that gets the substitution value for a given template field + default is get_template_value which handles the single-value fields """ + + if get_func is None: + # used by make_subst_function to get the value for a template substitution + get_func = partial( + self.get_template_value, + filename=filename, + dirname=dirname, + replacement=replacement, + ) + + # closure to capture photo, none_str, filename, dirname in subst + def subst(matchobj): + groups = len(matchobj.groups()) + if groups != 5: + raise ValueError( + f"Unexpected number of groups: expected 4, got {groups}" + ) + + delim = matchobj.group(1) + field = matchobj.group(2) + path_sep = matchobj.group(3) + bool_val = matchobj.group(4) + default = matchobj.group(5) + + # drop the '+' on delim + delim = delim[:-1] if delim is not None else None + # drop () from path_sep + path_sep = path_sep.strip("()") if path_sep is not None else None + # drop the ? on bool_val + bool_val = bool_val[1:] if bool_val is not None else None + # drop the comma on default + default_val = default[1:] if default is not None else None + + try: + val = get_func(field, default_val, bool_val, delim, path_sep) + except ValueError: + return matchobj.group(0) + + if val is None: + # field valid but didn't match a value + if default == ",": + val = "" + else: + val = default_val if default_val is not None else none_str + + return val + + return subst + def render( self, template, @@ -208,60 +269,7 @@ class PhotoTemplate: if type(template) is not str: raise TypeError(f"template must be type str, not {type(template)}") - # used by make_subst_function to get the value for a template substitution - get_func = partial( - self.get_template_value, - filename=filename, - dirname=dirname, - replacement=replacement, - ) - - def make_subst_function(self, none_str, get_func=get_func): - """ returns: substitution function for use in re.sub - none_str: value to use if substitution lookup is None and no default provided - get_func: function that gets the substitution value for a given template field - default is get_template_value which handles the single-value fields """ - - # closure to capture photo, none_str, filename, dirname in subst - def subst(matchobj): - groups = len(matchobj.groups()) - if groups == 5: - delim = matchobj.group(1) - field = matchobj.group(2) - path_sep = matchobj.group(3) - bool_val = matchobj.group(4) - default = matchobj.group(5) - - # drop the '+' on delim - delim = delim[:-1] if delim is not None else None - # drop () from path_sep - path_sep = path_sep.strip("()") if path_sep is not None else None - # drop the ? on bool_val - bool_val = bool_val[1:] if bool_val is not None else None - # drop the comma on default - default_val = default[1:] if default is not None else None - - try: - val = get_func(field, default_val, bool_val, delim, path_sep) - except ValueError: - return matchobj.group(0) - - if val is None: - # field valid but didn't match a value - if default == ",": - val = "" - else: - val = default_val if default_val is not None else none_str - - return val - else: - raise ValueError( - f"Unexpected number of groups: expected 4, got {groups}" - ) - - return subst - - subst_func = make_subst_function(self, none_str) + subst_func = self.make_subst_function(none_str, filename, dirname, replacement) # do the replacements rendered = re.sub(regex, subst_func, template) @@ -289,88 +297,28 @@ class PhotoTemplate: # '2011/Album2/keyword1/person1', # '2011/Album2/keyword2/person1',] - rendered_strings = [rendered] - for field in MULTI_VALUE_SUBSTITUTIONS: - # Build a regex that matches only the field being processed - re_str = ( - r"(?