Implemented issue #255

This commit is contained in:
Rhet Turnbull
2020-11-07 18:22:17 -08:00
parent 9588853ea2
commit ae2fd2e3db
5 changed files with 198 additions and 35 deletions

View File

@@ -388,6 +388,18 @@ option to re-export the entire library thus rebuilding the
** Templating System ** ** Templating System **
Several options, such as --directory, allow you to specify a template which
will be rendered to substitute template fields with values from the photo. For
example, '{created.month}' would be replaced with the month name of the photo
creation date. e.g. 'November'. The general format for a template is
'{TEMPLATE_FIELD[,[DEFAULT]]}'. The ',' and DEFAULT value are optional. If
TEMPLATE_FIELD results in a null (empty) value, the default is '_'. You may
specify an alternate default value by appending ',DEFAULT' after
template_field. e.g. '{title,no_title}' would result in 'no_title' if the
photo had no title. You may include other text in the template string outside
the {} and use more than one template field, e.g. '{created.year} -
{created.month}' (e.g. '2020 - November').
With the --directory and --filename options you may specify a template for the With the --directory and --filename options you may specify a template for the
export directory or filename, respectively. The directory will be appended to export directory or filename, respectively. The directory will be appended to
the export path specified in the export DEST argument to export. For example, the export path specified in the export DEST argument to export. For example,
@@ -426,12 +438,26 @@ value, '_' (underscore) will be used as the default value. For example, in the
above example, this would result in '2020/_/photoname.jpg' if address was above example, this would result in '2020/_/photoname.jpg' if address was
null. null.
You may specify a null default (e.g. "" or empty string) by omitting the value
after the comma, e.g. {title,} which would render to "" if title had no value.
Substitution Description Substitution Description
{name} Current filename of the photo {name} Current filename of the photo
{original_name} Photo's original filename when imported to {original_name} Photo's original filename when imported to
Photos Photos
{title} Title of the photo {title} Title of the photo
{descr} Description of the photo {descr} Description of the photo
{media_type} Special media type resolved in this
precedence: selfie, time_lapse, panorama,
slow_mo, screenshot, portrait, live_photo,
burst, photo, video. 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}'
{created.date} Photo's creation date in ISO format, e.g. {created.date} Photo's creation date in ISO format, e.g.
'2020-03-22' '2020-03-22'
{created.year} 4-digit year of photo creation time {created.year} 4-digit year of photo creation time
@@ -1835,38 +1861,40 @@ To get the path of every raw photo, whether it's a single raw photo or a raw+JPE
### Template Substitutions ### Template Substitutions
The following substitutions are availabe for use with `PhotoInfo.render_template()` The following template field substitutions are availabe for use with `PhotoInfo.render_template()`
| Substitution | Description | | Substitution | Description |
|--------------|-------------| |--------------|-------------|
|{name}|Current filename of the photo| |{name}|Current filename of the photo|
|{original_name}|Photo's original filename when imported to Photos| |{original_name}|Photo's original filename when imported to Photos|
|{title}|Title of the photo| |{title}|Title of the photo|
|{descr}|Description of the photo| |{descr}|Description of the photo|
|{media_type}|Special media type resolved in this precedence: selfie, time_lapse, panorama, slow_mo, screenshot, portrait, live_photo, burst, photo, video. 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}'|
|{created.date}|Photo's creation date in ISO format, e.g. '2020-03-22'| |{created.date}|Photo's creation date in ISO format, e.g. '2020-03-22'|
|{created.year}|4-digit year of file creation time| |{created.year}|4-digit year of photo creation time|
|{created.yy}|2-digit year of file creation time| |{created.yy}|2-digit year of photo creation time|
|{created.mm}|2-digit month of the file creation time (zero padded)| |{created.mm}|2-digit month of the photo creation time (zero padded)|
|{created.month}|Month name in user's locale of the file creation time| |{created.month}|Month name in user's locale of the photo creation time|
|{created.mon}|Month abbreviation in the user's locale of the file 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 file 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 file 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 file creation time, starting from 1 (zero padded)| |{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 file creation time| |{created.hour}|2-digit hour of the photo creation time|
|{created.min}|2-digit minute of the file creation time| |{created.min}|2-digit minute of the photo creation time|
|{created.sec}|2-digit second of the file 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.| |{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'| |{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'|
|{modified.year}|4-digit year of file modification time| |{modified.year}|4-digit year of photo modification time|
|{modified.yy}|2-digit year of file modification time| |{modified.yy}|2-digit year of photo modification time|
|{modified.mm}|2-digit month of the file modification time (zero padded)| |{modified.mm}|2-digit month of the photo modification time (zero padded)|
|{modified.month}|Month name in user's locale of the file modification time| |{modified.month}|Month name in user's locale of the photo modification time|
|{modified.mon}|Month abbreviation in the user's locale of the file modification time| |{modified.mon}|Month abbreviation in the user's locale of the photo modification time|
|{modified.dd}|2-digit day of the month (zero padded) of the file modification time| |{modified.dd}|2-digit day of the month (zero padded) of the photo modification time|
|{modified.dow}|Day of week in user's locale of the photo modification time| |{modified.dow}|Day of week in user's locale of the photo modification time|
|{modified.doy}|3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)| |{modified.doy}|3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded)|
|{modified.hour}|2-digit hour of the file modification time| |{modified.hour}|2-digit hour of the photo modification time|
|{modified.min}|2-digit minute of the file modification time| |{modified.min}|2-digit minute of the photo modification time|
|{modified.sec}|2-digit second of the file modification time| |{modified.sec}|2-digit second of the photo modification time|
|{today.date}|Current date in iso format, e.g. '2020-03-22'| |{today.date}|Current date in iso format, e.g. '2020-03-22'|
|{today.year}|4-digit year of current date| |{today.year}|4-digit year of current date|
|{today.yy}|2-digit year of current date| |{today.yy}|2-digit year of current date|

View File

@@ -158,6 +158,19 @@ class ExportCommand(click.Command):
formatter.write("\n\n") formatter.write("\n\n")
formatter.write_text("** Templating System **") formatter.write_text("** Templating System **")
formatter.write("\n") formatter.write("\n")
formatter.write_text("Several options, such as --directory, allow you to specify a template "
+ "which will be rendered to substitute template fields with values from the photo. "
+ "For example, '{created.month}' would be replaced with the month name of the photo creation date. "
+ "e.g. 'November'. "
+ "The general format for a template is '{TEMPLATE_FIELD[,[DEFAULT]]}'. "
+ "The ',' and DEFAULT value are optional. "
+ "If TEMPLATE_FIELD results in a null (empty) value, the default is '_'. "
+ "You may specify an alternate default value by appending ',DEFAULT' after template_field. "
+ "e.g. '{title,no_title}' would result in 'no_title' if the photo had no title. "
+ "You may include other text in the template string outside the {} and use more than "
+ "one template field, e.g. '{created.year} - {created.month}' (e.g. '2020 - November')."
)
formatter.write("\n")
formatter.write_text( formatter.write_text(
"With the --directory and --filename options you may specify a template for the " "With the --directory and --filename options you may specify a template for the "
+ "export directory or filename, respectively. " + "export directory or filename, respectively. "

View File

@@ -1,4 +1,4 @@
""" version info """ """ version info """
__version__ = "0.36.9" __version__ = "0.36.10"

View File

@@ -23,12 +23,33 @@ from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
# ensure locale set to user's locale # ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "") locale.setlocale(locale.LC_ALL, "")
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) # Permitted substitutions (each of these returns a single value or None)
TEMPLATE_SUBSTITUTIONS = { TEMPLATE_SUBSTITUTIONS = {
"{name}": "Current filename of the photo", "{name}": "Current filename of the photo",
"{original_name}": "Photo's original filename when imported to Photos", "{original_name}": "Photo's original filename when imported to Photos",
"{title}": "Title of the photo", "{title}": "Title of the photo",
"{descr}": "Description 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}'",
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'", "{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
"{created.year}": "4-digit year of photo creation time", "{created.year}": "4-digit year of photo creation time",
"{created.yy}": "2-digit year of photo creation time", "{created.yy}": "2-digit year of photo creation time",
@@ -174,9 +195,9 @@ class PhotoTemplate:
# there would be 6 possible renderings (2 albums x 3 persons) # there would be 6 possible renderings (2 albums x 3 persons)
# regex to find {template_field,optional_default} in strings # regex to find {template_field,optional_default} in strings
# for explanation of regex see https://regex101.com/r/MbOlJV/4 # for explanation of regex see https://regex101.com/r/YFpWsn/1
# pylint: disable=anomalous-backslash-in-string # pylint: disable=anomalous-backslash-in-string
regex = r"(?<!\{)\{([^}]*\+)?([^\\,}+]+)(,{0,1}([\w\-\%. ]+)?)(?=\}(?!\}))\}" regex = r"(?<!\{)\{([^}]*\+)?([^\\,}+\?]+)(\?[^\\,}]*)?(,{0,1}([\w\=\;\-\%. ]+)?)(?=\}(?!\}))\}"
if type(template) is not str: if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}") raise TypeError(f"template must be type str, not {type(template)}")
@@ -197,11 +218,12 @@ class PhotoTemplate:
# closure to capture photo, none_str, filename, dirname in subst # closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj): def subst(matchobj):
groups = len(matchobj.groups()) groups = len(matchobj.groups())
if groups == 4: if groups == 5:
delim = matchobj.group(1) delim = matchobj.group(1)
field = matchobj.group(2) field = matchobj.group(2)
default = matchobj.group(3) boolval = matchobj.group(3)
default_val = matchobj.group(4) default = matchobj.group(4)
default_val = matchobj.group(5)
try: try:
val = get_func(field, default_val) val = get_func(field, default_val)
except ValueError: except ValueError:
@@ -212,11 +234,7 @@ class PhotoTemplate:
if default == ",": if default == ",":
val = "" val = ""
else: else:
val = ( val = default_val if default_val is not None else none_str
default_val
if default_val is not None
else none_str
)
return val return val
else: else:
@@ -257,7 +275,11 @@ class PhotoTemplate:
rendered_strings = [rendered] rendered_strings = [rendered]
for field in MULTI_VALUE_SUBSTITUTIONS: for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed # Build a regex that matches only the field being processed
re_str = r"(?<!\{)\{([^}]*\+)?(" + field + r")(,{0,1}([\w\-\%. ]+)?)(?=\}(?!\}))\}" re_str = (
r"(?<!\{)\{([^}]*\+)?("
+ field
+ r")(\?[^\\,}]*)?(,{0,1}([\w\=\;\-\%. ]+)?)(?=\}(?!\}))\}"
)
regex_multi = re.compile(re_str) regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys()) # holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
@@ -380,6 +402,10 @@ class PhotoTemplate:
value = self.photo.title value = self.photo.title
elif field == "descr": elif field == "descr":
value = self.photo.description 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 == "created.date": elif field == "created.date":
value = DateTimeFormatter(self.photo.date).date value = DateTimeFormatter(self.photo.date).date
elif field == "created.year": elif field == "created.year":
@@ -675,3 +701,60 @@ class PhotoTemplate:
values = values or [None] values = values or [None]
return values 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 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.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_

View File

@@ -7,8 +7,8 @@ PHOTOS_DB_PLACES = (
PHOTOS_DB_15_1 = "./tests/Test-10.15.1.photoslibrary/database/photos.db" PHOTOS_DB_15_1 = "./tests/Test-10.15.1.photoslibrary/database/photos.db"
PHOTOS_DB_15_4 = "./tests/Test-10.15.4.photoslibrary/database/photos.db" PHOTOS_DB_15_4 = "./tests/Test-10.15.4.photoslibrary/database/photos.db"
PHOTOS_DB_14_6 = "./tests/Test-10.14.6.photoslibrary/database/photos.db" PHOTOS_DB_14_6 = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
PHOTOS_DB_COMMENTS = "tests/Test-Cloud-10.15.6.photoslibrary" PHOTOS_DB_COMMENTS = "tests/Test-Cloud-10.15.6.photoslibrary"
PHOTOS_DB_CLOUD = "./tests/Test-Cloud-10.15.6.photoslibrary/database/photos.db"
UUID_DICT = { UUID_DICT = {
"place_dc": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546", "place_dc": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
@@ -22,6 +22,19 @@ UUID_DICT = {
"date_not_modified": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546", "date_not_modified": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
} }
UUID_MEDIA_TYPE = {
"photo": "C2BBC7A4-5333-46EE-BAF0-093E72111B39",
"video": "45099D34-A414-464F-94A2-60D6823679C8",
"selfie": "080525C4-1F05-48E5-A3F4-0C53127BB39C",
"time_lapse": "4614086E-C797-4876-B3B9-3057E8D757C9",
"panorama": "1C1C8F1F-826B-4A24-B1CB-56628946A834",
"slow_mo": None,
"screenshot": None,
"portrait": "7CDA5F84-AA16-4D28-9AA6-A49E1DF8A332",
"live_photo": "51F2BEF7-431A-4D31-8AC1-3284A57826AE",
"burst": None,
}
TEMPLATE_VALUES = { TEMPLATE_VALUES = {
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546", "{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
"{original_name}": "IMG_1064", "{original_name}": "IMG_1064",
@@ -559,3 +572,29 @@ def test_comment():
photo = photosdb.get_photo(uuid) photo = photosdb.get_photo(uuid)
comments = photo.render_template("{comment}") comments = photo.render_template("{comment}")
assert comments[0] == COMMENT_UUID_DICT[uuid] assert comments[0] == COMMENT_UUID_DICT[uuid]
def test_media_type():
""" test {media_type} template """
import osxphotos
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
for field, uuid in UUID_MEDIA_TYPE.items():
if uuid is not None:
photo = photosdb.get_photo(uuid)
rendered, _ = photo.render_template("{media_type}")
assert rendered[0] == osxphotos.phototemplate.MEDIA_TYPE_DEFAULTS[field]
def test_media_type_default():
""" test {media_type,photo=foo} template style """
import osxphotos
photosdb = osxphotos.PhotosDB(PHOTOS_DB_CLOUD)
for field, uuid in UUID_MEDIA_TYPE.items():
if uuid is not None:
photo = photosdb.get_photo(uuid)
rendered, _ = photo.render_template("{media_type," + f"{field}" + "=foo}")
assert rendered[0] == "foo"