osxphotos/osxphotos/phototemplate.py

1236 lines
52 KiB
Python

""" Custom template system for osxphotos, implements osxphotos template language (OTL) """
import datetime
import locale
import os
import pathlib
from textx import TextXSyntaxError, metamodel_from_file
from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifToolCaching
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
from .utils import load_function
# ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "")
OTL_GRAMMAR_MODEL = str(pathlib.Path(__file__).parent / "phototemplate.tx")
"""TextX metamodel for osxphotos template language """
PHOTO_VIDEO_TYPE_DEFAULTS = {"photo": "photo", "video": "video"}
MEDIA_TYPE_DEFAULTS = {
"selfie": "selfie",
"time_lapse": "time_lapse",
"panorama": "panorama",
"slow_mo": "slow_mo",
"screenshot": "screenshot",
"portrait": "portrait",
"live_photo": "live_photo",
"burst": "burst",
"photo": "photo",
"video": "video",
}
# Permitted substitutions (each of these returns a single value or None)
TEMPLATE_SUBSTITUTIONS = {
"{name}": "Current filename of the photo",
"{original_name}": "Photo's original filename when imported to Photos",
"{title}": "Title of the photo",
"{descr}": "Description of the photo",
"{media_type}": (
f"Special media type resolved in this precedence: {', '.join(t for t in MEDIA_TYPE_DEFAULTS)}. "
"Defaults to 'photo' or 'video' if no special type. "
"Customize one or more media types using format: '{media_type,video=vidéo;time_lapse=vidéo_accélérée}'"
),
"{photo_or_video}": "'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'",
"{hdr}": "Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
"{edited}": "True if photo has been edited (has adjustments), otherwise False; use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
"{edited_version}": "True if template is being rendered for the edited version of a photo, otherwise False. ",
"{favorite}": "Photo has been marked as favorite?; True/False value, use in format '{favorite?VALUE_IF_TRUE,VALUE_IF_FALSE}'",
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
"{created.year}": "4-digit year of photo creation time",
"{created.yy}": "2-digit year of photo creation time",
"{created.mm}": "2-digit month of the photo creation time (zero padded)",
"{created.month}": "Month name in user's locale of the photo creation time",
"{created.mon}": "Month abbreviation in the user's locale of the photo creation time",
"{created.dd}": "2-digit day of the month (zero padded) of photo creation time",
"{created.dow}": "Day of week in user's locale of the photo creation time",
"{created.doy}": "3-digit day of year (e.g Julian day) of photo creation time, starting from 1 (zero padded)",
"{created.hour}": "2-digit hour of the photo creation time",
"{created.min}": "2-digit minute of the photo creation time",
"{created.sec}": "2-digit second of the photo creation time",
"{created.strftime}": "Apply strftime template to file creation date/time. Should be used in form "
+ "{created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+ "{created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+ "If used with no template will return null value. "
+ "See https://strftime.org/ for help on strftime templates.",
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified",
"{modified.year}": "4-digit year of photo modification time; uses creation date if photo is not modified",
"{modified.yy}": "2-digit year of photo modification time; uses creation date if photo is not modified",
"{modified.mm}": "2-digit month of the photo modification time (zero padded); uses creation date if photo is not modified",
"{modified.month}": "Month name in user's locale of the photo modification time; uses creation date if photo is not modified",
"{modified.mon}": "Month abbreviation in the user's locale of the photo modification time; uses creation date if photo is not modified",
"{modified.dd}": "2-digit day of the month (zero padded) of the photo modification time; uses creation date if photo is not modified",
"{modified.dow}": "Day of week in user's locale of the photo modification time; uses creation date if photo is not modified",
"{modified.doy}": "3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded); uses creation date if photo is not modified",
"{modified.hour}": "2-digit hour of the photo modification time; uses creation date if photo is not modified",
"{modified.min}": "2-digit minute of the photo modification time; uses creation date if photo is not modified",
"{modified.sec}": "2-digit second of the photo modification time; uses creation date if photo is not modified",
"{modified.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
+ "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+ "{modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+ "If used with no template will return null value. Uses creation date if photo is not modified. "
+ "See https://strftime.org/ for help on strftime templates.",
"{today.date}": "Current date in iso format, e.g. '2020-03-22'",
"{today.year}": "4-digit year of current date",
"{today.yy}": "2-digit year of current date",
"{today.mm}": "2-digit month of the current date (zero padded)",
"{today.month}": "Month name in user's locale of the current date",
"{today.mon}": "Month abbreviation in the user's locale of the current date",
"{today.dd}": "2-digit day of the month (zero padded) of current date",
"{today.dow}": "Day of week in user's locale of the current date",
"{today.doy}": "3-digit day of year (e.g Julian day) of current date, starting from 1 (zero padded)",
"{today.hour}": "2-digit hour of the current date",
"{today.min}": "2-digit minute of the current date",
"{today.sec}": "2-digit second of the current date",
"{today.strftime}": "Apply strftime template to current date/time. Should be used in form "
+ "{today.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+ "{today.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+ "If used with no template will return null value. "
+ "See https://strftime.org/ for help on strftime templates.",
"{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
"{place.country_code}": "The ISO country code from the photo's reverse geolocation data",
"{place.name.country}": "Country name from the photo's reverse geolocation data",
"{place.name.state_province}": "State or province name from the photo's reverse geolocation data",
"{place.name.city}": "City or locality name from the photo's reverse geolocation data",
"{place.name.area_of_interest}": "Area of interest name (e.g. landmark or public place) from the photo's reverse geolocation data",
"{place.address}": "Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States'",
"{place.address.street}": "Street part of the postal address, e.g. '2007 18th St NW'",
"{place.address.city}": "City part of the postal address, e.g. 'Washington'",
"{place.address.state_province}": "State/province part of the postal address, e.g. 'DC'",
"{place.address.postal_code}": "Postal code part of the postal address, e.g. '20009'",
"{place.address.country}": "Country name of the postal address, e.g. 'United States'",
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
"{searchinfo.season}": "Season of the year associated with a photo, e.g. 'Summer'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{exif.camera_make}": "Camera make from original photo's EXIF information as imported by Photos, e.g. 'Apple'",
"{exif.camera_model}": "Camera model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s'",
"{exif.lens_model}": "Lens model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'",
"{uuid}": "Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'",
"{comma}": "A comma: ','",
"{semicolon}": "A semicolon: ';'",
"{questionmark}": "A question mark: '?'",
"{pipe}": "A vertical pipe: '|'",
"{openbrace}": "An open brace: '{'",
"{closebrace}": "A close brace: '}'",
"{openparens}": "An open parentheses: '('",
"{closeparens}": "A close parentheses: ')'",
"{openbracket}": "An open bracket: '['",
"{closebracket}": "A close bracket: ']'",
"{newline}": r"A newline: '\n'",
"{lf}": r"A line feed: '\n', alias for {newline}",
"{cr}": r"A carriage return: '\r'",
"{crlf}": r"a carriage return + line feed: '\r\n'",
}
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{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). "
"Labels are added automatically by Photos using machine learning algorithms to categorize images. "
"These are not the same as {keyword} which refers to the user-defined keywords/tags applied in Photos.",
"{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}": "Format: '{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.",
"{searchinfo.holiday}": "Holiday names associated with a photo, e.g. 'Christmas Day'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{searchinfo.activity}": "Activities associated with a photo, e.g. 'Sporting Event'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{searchinfo.venue}": "Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{searchinfo.venue_type}": "Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
"{photo}": "Provides direct access to the PhotoInfo object for the photo. "
+ "Must be used in format '{photo.property}' where 'property' represents a PhotoInfo property. "
+ "For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. "
+ "'{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of "
+ "the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
"{function}": "Execute a python function from an external file and use return value as template substitution. "
+ "Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. "
+ "The function will be passed the PhotoInfo object for the photo. "
+ "See https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py for an example of how to implement a template function.",
}
FILTER_VALUES = {
"lower": "Convert value to lower case, e.g. 'Value' => 'value'.",
"upper": "Convert value to upper case, e.g. 'Value' => 'VALUE'.",
"strip": "Strip whitespace from beginning/end of value, e.g. ' Value ' => 'Value'.",
"titlecase": "Convert value to title case, e.g. 'my value' => 'My Value'.",
"capitalize": "Capitalize first word of value and convert other words to lower case, e.g. 'MY VALUE' => 'My value'.",
"braces": "Enclose value in curly braces, e.g. 'value => '{value}'.",
"parens": "Enclose value in parentheses, e.g. 'value' => '(value')",
"brackets": "Enclose value in brackets, e.g. 'value' => '[value]'",
"function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py"
}
# Just the substitutions without the braces
SINGLE_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS
]
# Just the multi-valued substitution names without the braces
MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
]
FIELD_NAMES = SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS
# default values for string manipulation template options
INPLACE_DEFAULT = ","
PATH_SEP_DEFAULT = os.path.sep
PUNCTUATION = {
"comma": ",",
"semicolon": ";",
"pipe": "|",
"openbrace": "{",
"closebrace": "}",
"openparens": "(",
"closeparens": ")",
"openbracket": "[",
"closebracket": "]",
"questionmark": "?",
"newline": "\n",
"lf": "\n",
"cr": "\r",
"crlf": "\r\n",
}
class PhotoTemplateParser:
"""Parser for PhotoTemplate """
# implemented as Singleton
def __new__(cls, *args, **kwargs):
""" create new object or return instance of already created singleton """
if not hasattr(cls, "instance") or not cls.instance:
cls.instance = super().__new__(cls)
return cls.instance
def __init__(self):
""" return existing singleton or create a new one """
if hasattr(self, "metamodel"):
return
self.metamodel = metamodel_from_file(OTL_GRAMMAR_MODEL, skipws=False)
def parse(self, template_statement):
"""Parse a template_statement string """
return self.metamodel.model_from_str(template_statement)
class PhotoTemplate:
""" PhotoTemplate class to render a template string from a PhotoInfo object """
def __init__(self, photo, exiftool_path=None):
""" Inits PhotoTemplate class with photo
Args:
photo: a PhotoInfo instance.
exiftool_path: optional path to exiftool for use with {exiftool:} template; if not provided, will look for exiftool in $PATH
"""
self.photo = photo
self.exiftool_path = exiftool_path
# holds value of current date/time for {today.x} fields
# gets initialized in get_template_value
self.today = None
# get parser singleton
self.parser = PhotoTemplateParser()
# should {edited_version} render True?
self.edited_version = False
def render(
self,
template,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
strip=False,
edited_version=False,
):
""" Render a filename or directory template
Args:
template: str template
none_str: str to use default for None values, default is '_'
path_sep: optional string 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 ','
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
strip: if True, strips leading/trailing whitespace from rendered templates
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
if path_sep is None:
path_sep = PATH_SEP_DEFAULT
if inplace_sep is None:
inplace_sep = INPLACE_DEFAULT
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
try:
model = self.parser.parse(template)
except TextXSyntaxError as e:
raise ValueError(f"SyntaxError: {e}")
if not model:
# empty string
return [], []
self.edited_version = edited_version
return self._render_statement(
model,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
strip=strip,
)
def _render_statement(
self,
statement,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
strip=False,
):
results = []
unmatched = []
for ts in statement.template_strings:
results, unmatched = self._render_template_string(
ts,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
results=results,
unmatched=unmatched,
)
rendered_strings = results
if filename:
rendered_strings = [
sanitize_filename(rendered_str) for rendered_str in rendered_strings
]
if strip:
rendered_strings = [
rendered_str.strip() for rendered_str in rendered_strings
]
return rendered_strings, unmatched
def _render_template_string(
self,
ts,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
results=None,
unmatched=None,
):
"""Render a TemplateString object """
results = results or [""]
unmatched = unmatched or []
if ts.template:
# have a template field to process
field = ts.template.field
if field not in FIELD_NAMES and not field.startswith("photo"):
unmatched.append(field)
return [], unmatched
subfield = ts.template.subfield
# process filters
filters = []
if ts.template.filter is not None:
filters = ts.template.filter.value
# process path_sep
if ts.template.pathsep is not None:
path_sep = ts.template.pathsep.value
# process delim
if ts.template.delim is not None:
# if value is None, means format was {+field}
delim = ts.template.delim.value or ""
else:
delim = None
if ts.template.bool is not None:
is_bool = True
if ts.template.bool.value is not None:
bool_val, u = self._render_statement(
ts.template.bool.value,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
)
unmatched.extend(u)
else:
# blank bool value
bool_val = [""]
else:
is_bool = False
bool_val = None
# process default
if ts.template.default is not None:
# default is also a TemplateString
if ts.template.default.value is not None:
default, u = self._render_statement(
ts.template.default.value,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
)
unmatched.extend(u)
else:
# blank default value
default = [""]
else:
default = []
# process conditional
if ts.template.conditional is not None:
operator = ts.template.conditional.operator
negation = ts.template.conditional.negation
if ts.template.conditional.value is not None:
# conditional value is also a TemplateString
conditional_value, u = self._render_statement(
ts.template.conditional.value,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
)
unmatched.extend(u)
else:
# this shouldn't happen
conditional_value = [""]
else:
operator = None
negation = None
conditional_value = []
vals = []
if field in SINGLE_VALUE_SUBSTITUTIONS:
vals = self.get_template_value(
field,
default=default,
delim=delim or inplace_sep,
path_sep=path_sep,
filename=filename,
dirname=dirname,
)
elif field == "exiftool":
if subfield is None:
raise ValueError(
"SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'"
)
vals = self.get_template_value_exiftool(
subfield, filename=filename, dirname=dirname
)
elif field == "function":
if subfield is None:
raise ValueError(
"SyntaxError: filename and function must not be null with {function::filename.py:function_name}"
)
vals = self.get_template_value_function(
subfield, filename=filename, dirname=dirname
)
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
vals = self.get_template_value_multi(
field, path_sep=path_sep, filename=filename, dirname=dirname
)
else:
unmatched.append(field)
return [], unmatched
vals = [val for val in vals if val is not None]
if expand_inplace or delim is not None:
sep = delim if delim is not None else inplace_sep
vals = [sep.join(sorted(vals))]
for filter_ in filters:
vals = self.get_template_value_filter(filter_, vals)
# process find/replace
if ts.template.findreplace:
new_vals = []
for val in vals:
for pair in ts.template.findreplace.pairs:
find = pair.find or ""
repl = pair.replace or ""
val = val.replace(find, repl)
new_vals.append(val)
vals = new_vals
if operator:
# have a conditional operator
def string_test(test_function):
""" Perform string comparison using test_function; closure to capture conditional_value, vals, negation """
match = False
for c in conditional_value:
for v in vals:
if test_function(v, c):
match = True
break
if match:
break
if (match and not negation) or (negation and not match):
return ["True"]
else:
return []
def comparison_test(test_function):
""" Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation """
if len(vals) != 1 or len(conditional_value) != 1:
raise ValueError(
f"comparison operators may only be used with a single value: {vals} {conditional_value}"
)
try:
match = (
True
if test_function(
float(vals[0]), float(conditional_value[0])
)
else False
)
if (match and not negation) or (negation and not match):
return ["True"]
else:
return []
except ValueError as e:
raise ValueError(
f"comparison operators may only be used with values that can be converted to numbers: {vals} {conditional_value}"
)
if operator in ["contains", "matches", "startswith", "endswith"]:
# process any "or" values separated by "|"
temp_values = []
for c in conditional_value:
temp_values.extend(c.split("|"))
conditional_value = temp_values
if operator == "contains":
vals = string_test(lambda v, c: c in v)
elif operator == "matches":
vals = string_test(lambda v, c: v == c)
elif operator == "startswith":
vals = string_test(lambda v, c: v.startswith(c))
elif operator == "endswith":
vals = string_test(lambda v, c: v.endswith(c))
elif operator == "==":
match = sorted(vals) == sorted(conditional_value)
if (match and not negation) or (negation and not match):
vals = ["True"]
else:
vals = []
elif operator == "!=":
match = sorted(vals) != sorted(conditional_value)
if (match and not negation) or (negation and not match):
vals = ["True"]
else:
vals = []
elif operator == "<":
vals = comparison_test(lambda v, c: v < c)
elif operator == "<=":
vals = comparison_test(lambda v, c: v <= c)
elif operator == ">":
vals = comparison_test(lambda v, c: v > c)
elif operator == ">=":
vals = comparison_test(lambda v, c: v >= c)
if is_bool:
vals = default if not vals else bool_val
elif not vals:
vals = default or [none_str]
pre = ts.pre or ""
post = ts.post or ""
rendered = [pre + val + post for val in vals]
results_new = []
for ren in rendered:
for res in results:
res_new = res + ren
results_new.append(res_new)
results = results_new
else:
# no template
pre = ts.pre or ""
post = ts.post or ""
results = [r + pre + post for r in results]
return results, unmatched
def get_template_value(
self,
field,
default,
bool_val=None,
delim=None,
path_sep=None,
filename=False,
dirname=False,
):
"""lookup value for template field (single-value template substitutions)
Args:
field: template field to find value for.
default: the default value provided by the user
bool_val: True value if expression is boolean
delim: delimiter for expand in place
path_sep: path separator for fields that are path-like
filename: if True, template output will be sanitized to produce valid file name
dirname: if True, template output will be sanitized to produce valid directory name
Returns:
The matching template value (which may be None).
Raises:
ValueError if no rule exists for field.
"""
if field not in FIELD_NAMES:
raise ValueError(f"SyntaxError: Unknown field: {field}")
# initialize today with current date/time if needed
if self.today is None:
self.today = datetime.datetime.now()
value = None
# wouldn't a switch/case statement be nice...
if field == "name":
value = pathlib.Path(self.photo.filename).stem
elif field == "original_name":
value = pathlib.Path(self.photo.original_filename).stem
elif field == "title":
value = self.photo.title
elif field == "descr":
value = self.photo.description
elif field == "media_type":
value = self.get_media_type(default)
elif field == "photo_or_video":
value = self.get_photo_video_type(default)
elif field == "hdr":
value = "hdr" if self.photo.hdr else None
elif field == "edited":
value = "edited" if self.photo.hasadjustments else None
elif field == "edited_version":
value = "edited_version" if self.edited_version else None
elif field == "favorite":
value = "favorite" if self.photo.favorite else None
elif field == "created.date":
value = DateTimeFormatter(self.photo.date).date
elif field == "created.year":
value = DateTimeFormatter(self.photo.date).year
elif field == "created.yy":
value = DateTimeFormatter(self.photo.date).yy
elif field == "created.mm":
value = DateTimeFormatter(self.photo.date).mm
elif field == "created.month":
value = DateTimeFormatter(self.photo.date).month
elif field == "created.mon":
value = DateTimeFormatter(self.photo.date).mon
elif field == "created.dd":
value = DateTimeFormatter(self.photo.date).dd
elif field == "created.dow":
value = DateTimeFormatter(self.photo.date).dow
elif field == "created.doy":
value = DateTimeFormatter(self.photo.date).doy
elif field == "created.hour":
value = DateTimeFormatter(self.photo.date).hour
elif field == "created.min":
value = DateTimeFormatter(self.photo.date).min
elif field == "created.sec":
value = DateTimeFormatter(self.photo.date).sec
elif field == "created.strftime":
if default:
try:
value = self.photo.date.strftime(default[0])
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
value = None
elif field == "modified.date":
value = (
DateTimeFormatter(self.photo.date_modified).date
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).date
)
elif field == "modified.year":
value = (
DateTimeFormatter(self.photo.date_modified).year
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).year
)
elif field == "modified.yy":
value = (
DateTimeFormatter(self.photo.date_modified).yy
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).yy
)
elif field == "modified.mm":
value = (
DateTimeFormatter(self.photo.date_modified).mm
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).mm
)
elif field == "modified.month":
value = (
DateTimeFormatter(self.photo.date_modified).month
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).month
)
elif field == "modified.mon":
value = (
DateTimeFormatter(self.photo.date_modified).mon
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).mon
)
elif field == "modified.dd":
value = (
DateTimeFormatter(self.photo.date_modified).dd
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).dd
)
elif field == "modified.dow":
value = (
DateTimeFormatter(self.photo.date_modified).dow
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).dow
)
elif field == "modified.doy":
value = (
DateTimeFormatter(self.photo.date_modified).doy
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).doy
)
elif field == "modified.hour":
value = (
DateTimeFormatter(self.photo.date_modified).hour
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).hour
)
elif field == "modified.min":
value = (
DateTimeFormatter(self.photo.date_modified).min
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).min
)
elif field == "modified.sec":
value = (
DateTimeFormatter(self.photo.date_modified).sec
if self.photo.date_modified
else DateTimeFormatter(self.photo.date).sec
)
elif field == "modified.strftime":
if default:
try:
date = self.photo.date_modified or self.photo.date
value = date.strftime(default[0])
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
value = None
elif field == "today.date":
value = DateTimeFormatter(self.today).date
elif field == "today.year":
value = DateTimeFormatter(self.today).year
elif field == "today.yy":
value = DateTimeFormatter(self.today).yy
elif field == "today.mm":
value = DateTimeFormatter(self.today).mm
elif field == "today.month":
value = DateTimeFormatter(self.today).month
elif field == "today.mon":
value = DateTimeFormatter(self.today).mon
elif field == "today.dd":
value = DateTimeFormatter(self.today).dd
elif field == "today.dow":
value = DateTimeFormatter(self.today).dow
elif field == "today.doy":
value = DateTimeFormatter(self.today).doy
elif field == "today.hour":
value = DateTimeFormatter(self.today).hour
elif field == "today.min":
value = DateTimeFormatter(self.today).min
elif field == "today.sec":
value = DateTimeFormatter(self.today).sec
elif field == "today.strftime":
if default:
try:
value = self.today.strftime(default[0])
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
value = None
elif field == "place.name":
value = self.photo.place.name if self.photo.place else None
elif field == "place.country_code":
value = self.photo.place.country_code if self.photo.place else None
elif field == "place.name.country":
value = (
self.photo.place.names.country[0]
if self.photo.place and self.photo.place.names.country
else None
)
elif field == "place.name.state_province":
value = (
self.photo.place.names.state_province[0]
if self.photo.place and self.photo.place.names.state_province
else None
)
elif field == "place.name.city":
value = (
self.photo.place.names.city[0]
if self.photo.place and self.photo.place.names.city
else None
)
elif field == "place.name.area_of_interest":
value = (
self.photo.place.names.area_of_interest[0]
if self.photo.place and self.photo.place.names.area_of_interest
else None
)
elif field == "place.address":
value = (
self.photo.place.address_str
if self.photo.place and self.photo.place.address_str
else None
)
elif field == "place.address.street":
value = (
self.photo.place.address.street
if self.photo.place and self.photo.place.address.street
else None
)
elif field == "place.address.city":
value = (
self.photo.place.address.city
if self.photo.place and self.photo.place.address.city
else None
)
elif field == "place.address.state_province":
value = (
self.photo.place.address.state_province
if self.photo.place and self.photo.place.address.state_province
else None
)
elif field == "place.address.postal_code":
value = (
self.photo.place.address.postal_code
if self.photo.place and self.photo.place.address.postal_code
else None
)
elif field == "place.address.country":
value = (
self.photo.place.address.country
if self.photo.place and self.photo.place.address.country
else None
)
elif field == "place.address.country_code":
value = (
self.photo.place.address.iso_country_code
if self.photo.place and self.photo.place.address.iso_country_code
else None
)
elif field == "searchinfo.season":
value = self.photo.search_info.season if self.photo.search_info else None
elif field == "exif.camera_make":
value = self.photo.exif_info.camera_make if self.photo.exif_info else None
elif field == "exif.camera_model":
value = self.photo.exif_info.camera_model if self.photo.exif_info else None
elif field == "exif.lens_model":
value = self.photo.exif_info.lens_model if self.photo.exif_info else None
elif field == "uuid":
value = self.photo.uuid
elif field in PUNCTUATION:
value = PUNCTUATION[field]
else:
# if here, didn't get a match
raise ValueError(f"Unhandled template value: {field}")
if filename:
value = sanitize_pathpart(value)
elif dirname:
value = sanitize_dirname(value)
return [value]
def get_template_value_filter(self, filter_, values):
if filter_ == "lower":
if values and type(values) == list:
value = [v.lower() for v in values]
else:
value = [values.lower()] if values else []
elif filter_ == "upper":
if values and type(values) == list:
value = [v.upper() for v in values]
else:
value = [values.upper()] if values else []
elif filter_ == "strip":
if values and type(values) == list:
value = [v.strip() for v in values]
else:
value = [values.strip()] if values else []
elif filter_ == "capitalize":
if values and type(values) == list:
value = [v.capitalize() for v in values]
else:
value = [values.capitalize()] if values else []
elif filter_ == "titlecase":
if values and type(values) == list:
value = [v.title() for v in values]
else:
value = [values.title()] if values else []
elif filter_ == "braces":
if values and type(values) == list:
value = ["{" + v + "}" for v in values]
else:
value = ["{" + values + "}"] if values else []
elif filter_ == "parens":
if values and type(values) == list:
value = ["(" + v + ")" for v in values]
else:
value = ["(" + values + ")"] if values else []
elif filter_ == "brackets":
if values and type(values) == list:
value = ["[" + v + "]" for v in values]
else:
value = ["[" + values + "]"] if values else []
elif filter_.startswith("function:"):
value = self.get_template_value_filter_function(filter_, values)
else:
value = []
return value
def get_template_value_multi(self, field, path_sep, filename=False, dirname=False):
"""lookup value for template field (multi-value template substitutions)
Args:
field: template field to find value for.
path_sep: path separator to use for folder_album field
dirname: if True, values will be sanitized to be valid directory names; default = False
Returns:
List of the matching template values or [].
Raises:
ValueError if no rule exists for field.
"""
""" return list of values for a multi-valued template field """
values = []
if field == "album":
values = self.photo.burst_albums if self.photo.burst else self.photo.albums
elif field == "keyword":
values = self.photo.keywords
elif field == "person":
values = self.photo.persons
# remove any _UNKNOWN_PERSON values
values = [val for val in values if val != _UNKNOWN_PERSON]
elif field == "label":
values = self.photo.labels
elif field == "label_normalized":
values = self.photo.labels_normalized
elif field == "folder_album":
values = []
# photos must be in an album to be in a folder
if self.photo.burst:
album_info = self.photo.burst_album_info
else:
album_info = self.photo.album_info
for album in album_info:
if album.folder_names:
# album in folder
if dirname:
# being used as a filepath so sanitize each part
folder = path_sep.join(
sanitize_dirname(f) for f in album.folder_names
)
folder += path_sep + sanitize_dirname(album.title)
else:
folder = path_sep.join(album.folder_names)
folder += path_sep + album.title
values.append(folder)
else:
# album not in folder
if dirname:
values.append(sanitize_dirname(album.title))
else:
values.append(album.title)
elif field == "comment":
values = [
f"{comment.user}: {comment.text}" for comment in self.photo.comments
]
elif field == "searchinfo.holiday":
values = self.photo.search_info.holidays if self.photo.search_info else []
elif field == "searchinfo.activity":
values = self.photo.search_info.activities if self.photo.search_info else []
elif field == "searchinfo.venue":
values = self.photo.search_info.venues if self.photo.search_info else []
elif field == "searchinfo.venue_type":
values = (
self.photo.search_info.venue_types if self.photo.search_info else []
)
elif field.startswith("photo"):
# provide access to PhotoInfo object
properties = field.split(".")
if len(properties) <= 1:
raise ValueError(
"Missing property in {photo} template. Use in form {photo.property}."
)
obj = self.photo
for i in range(1, len(properties)):
property_ = properties[i]
try:
obj = getattr(obj, property_)
if obj is None:
break
except AttributeError:
raise ValueError(
"Invalid property for {photo} template: " + f"'{property_}'"
)
if obj is None:
values = []
elif isinstance(obj, bool):
values = [property_] if obj else []
elif isinstance(obj, (str, int, float)):
values = [str(obj)]
else:
values = [val for val in obj]
else:
raise ValueError(f"Unhandled template value: {field}")
# sanitize directory names if needed, folder_album handled differently above
if filename:
values = [sanitize_pathpart(value) for value in values]
elif dirname and field != "folder_album":
# skip folder_album because it would have been handled above
values = [sanitize_dirname(value) for value in values]
# If no values, insert None so code below will substitute none_str for None
values = values or []
return values
def get_template_value_exiftool(self, subfield, filename=None, dirname=None):
"""Get template value for format "{exiftool:EXIF:Model}" """
if not self.photo.path:
return []
exif = ExifToolCaching(self.photo.path, exiftool=self.exiftool_path)
exifdict = exif.asdict(normalized=True)
subfield = subfield.lower()
if subfield in exifdict:
values = exifdict[subfield]
values = [values] if not isinstance(values, list) else values
values = [str(v) for v in values]
# sanitize directory names if needed
if filename:
values = [sanitize_pathpart(value) for value in values]
elif dirname:
values = [sanitize_dirname(value) for value in values]
else:
values = []
return values
def get_template_value_function(self, subfield, filename=None, dirname=None):
"""Get template value from external function """
if "::" not in subfield:
raise ValueError(
f"SyntaxError: could not parse function name from '{subfield}'"
)
filename, funcname = subfield.split("::")
if not pathlib.Path(filename).is_file():
raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename, funcname)
values = template_func(self.photo)
if not isinstance(values, (str, list)):
raise TypeError(
f"Invalid return type for function {funcname}: expected str or list"
)
if type(values) == str:
values = [values]
# sanitize directory names if needed
if filename:
values = [sanitize_pathpart(value) for value in values]
elif dirname:
values = [sanitize_dirname(value) for value in values]
return values
def get_template_value_filter_function(self, filter_, values):
"""Filter template value from external function """
filter_ = filter_.replace("function:","")
if "::" not in filter_:
raise ValueError(
f"SyntaxError: could not parse function name from '{filter_}'"
)
filename, funcname = filter_.split("::")
if not pathlib.Path(filename).is_file():
raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename, funcname)
if not isinstance(values, (list, tuple)):
values = [values]
values = template_func(values)
if not isinstance(values, list):
raise TypeError(
f"Invalid return type for function {funcname}: expected list"
)
return values
def get_photo_video_type(self, default):
""" return media type, e.g. photo or video """
default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS)
if self.photo.isphoto:
return default_dict["photo"]
else:
return default_dict["video"]
def get_media_type(self, default):
""" return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type """
default_dict = parse_default_kv(default, MEDIA_TYPE_DEFAULTS)
p = self.photo
if p.selfie:
return default_dict["selfie"]
elif p.time_lapse:
return default_dict["time_lapse"]
elif p.panorama:
return default_dict["panorama"]
elif p.slow_mo:
return default_dict["slow_mo"]
elif p.screenshot:
return default_dict["screenshot"]
elif p.portrait:
return default_dict["portrait"]
elif p.live_photo:
return default_dict["live_photo"]
elif p.burst:
return default_dict["burst"]
elif p.ismovie:
return default_dict["video"]
else:
return default_dict["photo"]
def get_photo_bool_attribute(self, attr, default, bool_val):
# get value for a PhotoInfo bool attribute
val = getattr(self.photo, attr)
if val:
return bool_val
else:
return default
def parse_default_kv(default, default_dict):
""" parse a string in form key1=value1;key2=value2,... as used for some template fields
Args:
default: str, in form 'photo=foto;video=vidéo'
default_dict: dict, in form {"photo": "fotos", "video": "vidéos"} with default values
Returns:
dict in form {"photo": "fotos", "video": "vidéos"}
"""
default_dict_ = default_dict.copy()
if default:
defaults = default[0].split(";")
for kv in defaults:
try:
k, v = kv.split("=")
k = k.strip()
v = v.strip()
default_dict_[k] = v
except ValueError:
pass
return default_dict_
def get_template_help():
"""Return help for template system as markdown string """
# TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this
help_file = pathlib.Path(__file__).parent / "phototemplate.md"
with open(help_file, "r") as fd:
md = fd.read()
return md