Template refactor (#385)

* Initial implementation of new textx parser for template

* Implemented parser as singleton

* Moved grammar to .tx file

* Added filter templates

* Added filter templates

* Added tests for nested templates

* Added tests for filter+path_sep

* Added tests for filter+path_sep

* Added punctuation templates

* Added hook for --replace-keywords

* Updated docs for phototemplate

* Updated docs for phototemplate

* Updated tests data

* Updated tests data

* Updated docs for phototemplate

* Version bump

* Updated CLI help

* Fixed template processing for boolean, default
This commit is contained in:
Rhet Turnbull 2021-02-21 20:19:51 -08:00 committed by GitHub
parent 63bfa92563
commit 515df0a5dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1203 additions and 857 deletions

View File

@ -1,2 +1,5 @@
include README.md
include osxphotos/templates/*
include README.rst
include osxphotos/templates/*
include osxphotos/phototemplate.tx
include osxphotos/phototemplate.md

427
README.md
View File

@ -201,7 +201,7 @@ Options:
--uuid UUID Search for photos with UUID(s).
--uuid-from-file FILE Search for photos with UUID(s) loaded from
FILE. Format is a single UUID per line. Lines
preceeded with # are ignored.
preceded with # are ignored.
--title TITLE Search for TITLE in title of photo.
--no-title Search for photos with no title.
--description DESC Search for DESC in description of photo.
@ -457,7 +457,16 @@ Options:
"{folder_album}" You may specify more than one
template, for example --keyword-template
"{folder_album}" --keyword-template
"{created.year}" See Templating System below.
"{created.year}". See '--replace-keywords' and
Templating System below.
--replace-keywords Replace keywords with any values specified
with --keyword-template. By default,
--keyword-template will add keywords to any
keywords already associated with the photo.
If --replace-keywords is specified, values
from --keyword-template will replace any
existing keywords instead of adding additional
keywords.
--description-template TEMPLATE
For use with --exiftool, --sidecar; specify a
template string to use as description in the
@ -576,6 +585,7 @@ Options:
--help Show this message and exit.
** Export **
When exporting photos, osxphotos creates a database in the top-level export
folder called '.osxphotos_export.db'. This database preserves state information
used for determining which files need to be updated when run with --update. It
@ -652,119 +662,138 @@ s
** 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 templating system converts one or template statements, written in osxphotos
templating language, to one or more rendered values using information from the
photo being processed.
Some options supporting templates may be repeated e.g., --keyword-template
'{label}' --keyword-template '{media_type}' to add both labels and media types
to the keywords.
In its simplest form, a template statement has the form: "{template_field}", for
example "{title}" which would resolve to the title of the photo.
The general format for a template is '{TEMPLATE_FIELD,DEFAULT}'. The full
template format is:
'{DELIM+TEMPLATE_FIELD(PATH_SEP)[OLD,NEW]?VALUE_IF_TRUE,DEFAULT}'
Template statements may contain one or more modifiers. The full syntax is:
With a few exceptions (like '{created.strftime}') everything but the
TEMPLATE_FIELD is optional.
"pretext{delim+template_field:subfield|filter(path_sep)[find,replace]?bool_value
,default}posttext"
- 'DELIM+' Multi-value template fields such as '{keyword}' may be expanded 'in
place' with an optional delimiter using the template form
'{DELIM+TEMPLATE_FIELD}'. For example, a photo with keywords 'foo' and 'bar':
Template statements are white-space sensitive meaning that white space (spaces,
tabs) changes the meaning of the template statement.
'{keyword}' renders to 'foo' and 'bar'
pretext and posttext are free form text. For example, if a photo has title "My
Photo Title". the template statement "The title of the photo is {title}",
resolves to "The title of the photo is My Photo Title". The pretext in this
example is "The title if the photo is " and the template_field is {title}.
'{,+keyword}' renders to: 'foo,bar'
delim: optional delimiter string to use when expanding multi-valued template
values in-place
'{; +keyword}' renders to: 'foo; bar'
+: If present before template name, expands the template in place. If delim not
provided, values are joined with no delimiter.
'{+keyword}' renders to 'foobar'
e.g. if Photo keywords are ["foo","bar"]:
- 'TEMPLATE_FIELD' The name of the template field, for example 'keyword'
• "{keyword}" renders to "foo", "bar"
• "{,+keyword}" renders to: "foo,bar"
• "{; +keyword}" renders to: "foo; bar"
• "{+keyword}" renders to "foobar"
- '(PATH_SEP)' Some template fields such as '{folder_album}' are "path-like" in
that they join multiple elements into a single path-like string. For example,
if photo is in album Album1 in folder Folder1, '{folder_album}' results in
'Folder1/Album1'. This is so these template fields may be used as paths in
--directory. If you intend to use such a field as a string, e.g. in the
filename, you may specify a different path separator using the form:
'{TEMPLATE_FIELD(PATH_SEP)}'. For example, using the example above,
'{folder_album(-)}' would result in 'Folder1-Album1' and '{folder_album()}'
would result in 'Folder1Album1'.
template_field: The template field to resolve. See Template Substitutions for
full list of template fields.
- '[OLD,NEW]' Use the [OLD,NEW] option to replace text "OLD" in the template
value with text "NEW". For example, if you have album names with '/' in the
album name you could replace '/' with "-" using the template '{album[/,-]}'.
This would replace any occurence of "/" in the album name with "-"; album
"Vacation/2019" would thus become "Vacation-2019". You may specify more than
one pair of OLD,NEW values by listing them delimited by '|'. For example:
'{album[/,-|:,-]}' to replace both '/' and ':' by '-'. You can also use the
[OLD,NEW] syntax to delete a character by omitting the NEW value as in
'{album[/,]}'.
:subfield: Some templates have sub-fields, For example, {exiftool:IPTC:Make};
the template_field is exiftool and the sub-field is IPTC:Make.
- '?' Some template fields such as 'hdr' are boolean and resolve to True or
False. These take the form: '{TEMPLATE_FIELD?VALUE_IF_TRUE,VALUE_IF_FALSE}',
e.g. {hdr?is_hdr,not_hdr} which would result in 'is_hdr' if photo is an HDR
image and 'not_hdr' otherwise.
|filter: You may optionally append one or more filter commands to the end of the
template field using the vertical pipe ('|') symbol. Filters may be combined,
separated by '|' as in: {keyword|capitalize|parens}.
- ',DEFAULT' The ',' and DEFAULT value are optional. If TEMPLATE_FIELD results
in a null (empty) value, the template will result in default value of '_'. You
may specify an alternate default value by appending ',DEFAULT' after
template_field. Example: '{title,no_title}' would result in 'no_title' if the
photo had no title. Example: '{created.year}/{place.address,NO_ADDRESS}' but
there was no address associated with the photo, the resulting output would be:
'2020/NO_ADDRESS/photoname.jpg'. If specified, the default value may not contain
a brace symbol ('{' or '}').
Valid filters are:
Again, if you do not specify a default value and the template substitution has
no 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
null.
• 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]'
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
thus effectively deleting the template from the resulting string.
e.g. if Photo keywords are ["FOO","bar"]:
You may include other text in the template string outside the {} and use more
than one template field in a single string, e.g. '{created.year} -
{created.month}' (e.g. '2020 - November').
• "{keyword|lower}" renders to "foo", "bar"
• "{keyword|upper}" renders to: "FOO", "BAR"
• "{keyword|capitalize}" renders to: "Foo", "Bar"
• "{keyword|lower|parens}" renders to: "(foo)", "(bar)"
Some templates may resolve to more than one value. For example, a photo can
have multiple keywords so '{keyword}' can result in multiple values. If used in
a filename or directory, these templates may result in more than one copy of
the photo being exported. For example, if photo has keywords "foo" and "bar",
--directory '{keyword}' will result in copies of the photo being exported to
'foo/image_name.jpeg' and 'bar/image_name.jpeg'.
e.g. if Photo description is "my description":
Some template fields such as '{media_type}' use the 'DEFAULT' value to allow
customization of the output. For example, '{media_type}' resolves to the
special media type of the photo such as 'panorama' or 'selfie'. You may use
the 'DEFAULT' value to override these in form:
'{media_type,video=vidéo;time_lapse=vidéo_accélérée}'. In this example, if
photo is a time_lapse photo, 'media_type' would resolve to 'vidéo_accélérée'
instead of 'time_lapse' and video would resolve to 'vidéo' if photo is an
ordinary video.
• "{descr|titlecase}" renders to: "My Description"
With the --directory and --filename options you may specify a template for the
export directory or filename, respectively. The directory will be appended to
the export path specified in the export DEST argument to export. For example, if
template is '{created.year}/{created.month}', and export destination DEST is
'/Users/maria/Pictures/export', the actual export directory for a photo would be
'/Users/maria/Pictures/export/2020/March' if the photo was created in March
2020.
(path_sep): optional path separator to use when joining path-like fields, for
example {folder_album}. Default is "/".
The templating system may also be used with the --keyword-template option to set
keywords on export (with --exiftool or --sidecar), for example, to set a new
keyword in format 'folder/subfolder/album' to preserve the folder/album
structure, you can use --keyword-template "{folder_album}"
e.g. If Photo is in Album1 in Folder1:
In the template, valid template substitutions will be replaced by the
corresponding value from the table below. Invalid substitutions will result in
an error.
• "{folder_album}" renders to ["Folder1/Album1"]
• "{folder_album(>)}" renders to ["Folder1>Album1"]
• "{folder_album()}" renders to ["Folder1Album1"]
If you want the actual text of the template substition to appear in the rendered
name, use double braces, e.g. '{{' or '}}', thus using '{created.year}/{{name}}'
for --directory would result in output of 2020/{name}/photoname.jpg
[find|replace]: optional text replacement to perform on rendered template value.
For example, to replace "/" in an album name, you could use the template
"{album[/,-]}". Multiple replacements can be made by appending "|" and adding
another find|replace pair. e.g. to replace both "/" and ":" in album name:
"{album[/,-|:,-]}". find/replace pairs are not limited to single characters.
The "|" character cannot be used in a find/replace pair.
?bool_value: Template fields may be evaluated as boolean by appending "?" after
the field name (and following "(path_sep)" or "[find/replace]". If a field is
True (e.g. photo is HDR and field is "{hdr}") or has any value, the value
following the "?" will be used to render the template instead of the actual
field value. If the template field evaluates to False (e.g. in above example,
photo is not HDR) or has no value (e.g. photo has no title and field is
"{title}") then the default value following a "," will be used.
e.g. if photo is an HDR image,
• "{hdr?ISHDR,NOTHDR}" renders to "ISHDR"
and if it is not an HDR image,
• "{hdr?ISHDR,NOTHDR}" renders to "NOTHDR"
,default: optional default value to use if the template name has no value. This
modifier is also used for the value if False for boolean-type fields (see above)
as well as to hold a sub-template for values like {created.strftime}. If no
default value provided, "_" is used.
e.g., if photo has no title set,
• "{title}" renders to "_"
• "{title,I have no title}" renders to "I have no title"
Template fields such as created.strftime use the default value to pass the
template to use for strftime.
e.g., if photo date is 4 February 2020, 19:07:38,
• "{created.strftime,%Y-%m-%d-%H%M%S}" renders to "2020-02-04-190738"
Some template fields such as "{media_type}" use the default value to allow
customization of the output. For example, "{media_type}" resolves to the special
media type of the photo such as panorama or selfie. You may use the default
value to override these in form:
"{media_type,video=vidéo;time_lapse=vidéo_accélérée}". In this example, if photo
was a time_lapse photo, media_type would resolve to vidéo_accélérée instead of
time_lapse.
Either or both bool_value or default (False value) may be empty which would
result in empty string "" when rendered.
If you want to include "{" or "}" in the output, use "{openbrace}" or
"{closebrace}" template substitution.
e.g. "{created.year}/{openbrace}{title}{closebrace}" would result in
"2020/{Photo Title}".
With the --directory and --filename options you may specify a template for the
export directory or filename, respectively. The directory will be appended to
@ -777,29 +806,15 @@ if template is '{created.year}/{created.month}', and export destination DEST is
The templating system may also be used with the --keyword-template option to set
keywords on export (with --exiftool or --sidecar), for example, to set a new
keyword in format 'folder/subfolder/album' to preserve the folder/album
structure, you can use --keyword-template "{folder_album}"
structure, you can use --keyword-template "{folder_album}" or in the
'folder>subfolder>album' format used in Lightroom Classic, --keyword-template
"{folder_album(>)}".
In the template, valid template substitutions will be replaced by the
corresponding value from the table below. Invalid substitutions will result in
a an error and the script will abort.
If you want the actual text of the template substition to appear in the rendered
name, use double braces, e.g. '{{' or '}}', thus using '{created.year}/{{name}}'
for --directory would result in output of 2020/{name}/photoname.jpg
You may specify an optional default value to use if the substitution does not
contain a value (e.g. the value is null) by specifying the default value after a
',' in the template string: for example, if template is
'{created.year}/{place.address,NO_ADDRESS}' but there was no address associated
with the photo, the resulting output would be: '2020/NO_ADDRESS/photoname.jpg'.
If specified, the default value may not contain a brace symbol ('{' or '}').
If you do not specify a default value and the template substitution has no
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 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.
** Template Substitutions **
Substitution Description
{name} Current filename of the photo
@ -964,6 +979,15 @@ Substitution Description
(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: ';'
{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: ']'
The following substitutions may result in multiple values. Thus if specified for
--directory these could result in multiple copies of a photo being being
@ -972,39 +996,40 @@ 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)
{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).
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} 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).
```
@ -1850,76 +1875,117 @@ If overwrite=False and increment=False, export will fail if destination file alr
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
- `template_str`: str in format "{[[DELIM]+]name[(PATH_SEP)][?TRUE_VALUE][,[DEFAULT]]}" where name is one of the values in the [Template Substitutions](#template-substitutions) table. See notes below regarding specific details of the syntax.
- `template_str`: str in osxphotos template language (OTL) format. See also [Template Substitutions](#template-substitutions) table. See notes below regarding specific details of the syntax.
- `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`
- `path_sep`: optional character to use as path separator when joining path like fields such as `{folder_album}`; default is `os.path.sep`. May also be provided in the template itself. If provided both in the call to `render_template()` and in the template itself, the value in the template string takes precedence.
- `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, leading/trailign whitespace will be stripped from rendered template strings
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"].
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"]. If there are unmatched strings, rendered will be []. E.g. a template statement must fully match or will result in error and return all unmatched fields in unmatched.
e.g. `render_template("{created.year}/{foo}", photo)` would return `(["2020/{foo}"],["foo"])`
If you want to include "{" or "}" in the output, use "{{" or "}}"
e.g. `render_template("{created.year}/{{foo}}", photo)` would return `(["2020/{foo}"],[])`
e.g. `render_template("{created.year}/{foo}", photo)` would return `([],["foo"])`
Some substitutions, notably `album`, `keyword`, and `person` could return multiple values, hence a new string will be return for each possible substitution (hence why a list of rendered strings is returned). For example, a photo in 2 albums: 'Vacation' and 'Family' would result in the following rendered values if template was "{created.year}/{album}" and created.year == 2020: `["2020/Vacation","2020/Family"]`
The template field format contains optional modifiers:
<!-- OSXPHOTOS-TEMPLATE-HELP:START - Do not remove or modify this section -->
The templating system converts one or template statements, written in osxphotos templating language, to one or more rendered values using information from the photo being processed.
`"{DELIM+name(PATH_SEP)[OLD,NEW]?TRUE_VALUE,DEFAULT}"`
In its simplest form, a template statement has the form: `"{template_field}"`, for example `"{title}"` which would resolve to the title of the photo.
`DELIM`: optional delimiter string to use when expanding multi-valued template values in-place
Template statements may contain one or more modifiers. The full syntax is:
`+`: If present before template `name`, expands the template in place. If `DELIM` not provided, values are joined with no delimiter.
`"pretext{delim+template_field:subfield|filter(path_sep)[find,replace]?bool_value,default}posttext"`
Template statements are white-space sensitive meaning that white space (spaces, tabs) changes the meaning of the template statement.
`pretext` and `posttext` are free form text. For example, if a photo has title "My Photo Title". the template statement `"The title of the photo is {title}"`, resolves to `"The title of the photo is My Photo Title"`. The `pretext` in this example is `"The title if the photo is "` and the template_field is `{title}`.
`delim`: optional delimiter string to use when expanding multi-valued template values in-place
`+`: If present before template `name`, expands the template in place. If `delim` not provided, values are joined with no delimiter.
e.g. if Photo keywords are `["foo","bar"]`:
- `"{keyword}"` renders to `["foo", "bar"]`
- `"{,+keyword}"` renders to: `["foo,bar"]`
- `"{; +keyword}"` renders to: `["foo; bar"]`
- `"{+keyword}"` renders to `["foobar"]`
- `"{keyword}"` renders to `"foo", "bar"`
- `"{,+keyword}"` renders to: `"foo,bar"`
- `"{; +keyword}"` renders to: `"foo; bar"`
- `"{+keyword}"` renders to `"foobar"`
`PATH_SEP`: optional path separator to use when joining path like fields, for example `{folder_album}`. May also be provided as `path_sep` argument in `render_template()`. If provided both in the call to `render_template()` and in the template itself, the value in the template string takes precedence. If not provided in either the template string or in `path_sep` argument, defaults to `os.path.sep`.
`template_field`: The template field to resolve. See [Template Substitutions](#template-substitutions) for full list of template fields.
`:subfield`: Some templates have sub-fields, For example, `{exiftool:IPTC:Make}`; the template_field is `exiftool` and the sub-field is `IPTC:Make`.
`|filter`: You may optionally append one or more filter commands to the end of the template field using the vertical pipe ('|') symbol. Filters may be combined, separated by '|' as in: `{keyword|capitalize|parens}`.
Valid filters are:
<!-- OSXPHOTOS-FILTER-TABLE:START - Do not remove or modify this section -->
- 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]'
<!-- OSXPHOTOS-FILTER-TABLE:END -->
e.g. if Photo keywords are `["FOO","bar"]`:
- `"{keyword|lower}"` renders to `"foo", "bar"`
- `"{keyword|upper}"` renders to: `"FOO", "BAR"`
- `"{keyword|capitalize}"` renders to: `"Foo", "Bar"`
- `"{keyword|lower|parens}"` renders to: `"(foo)", "(bar)"`
e.g. if Photo description is "my description":
- `"{descr|titlecase}"` renders to: `"My Description"`
`(path_sep)`: optional path separator to use when joining path-like fields, for example `{folder_album}`. Default is "/".
e.g. If Photo is in `Album1` in `Folder1`:
- `"{folder_album}"` renders to `["Folder1/Album1"]`
- `"{folder_album(:)}"` renders to `["Folder1:Album1"]`
- `"{folder_album(>)}"` renders to `["Folder1>Album1"]`
- `"{folder_album()}"` renders to `["Folder1Album1"]`
`[OLD,NEW]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`.
`[find|replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair.
`?TRUE_VALUE`: optional value to use if name is boolean-type field which evaluates to true. For example `"{hdr}"` evaluates to True if photo is an high dynamic range (HDR) image and False otherwise. In these types of fields, use `?TRUE_VALUE` to provide the value if True and `,DEFAULT` to provide the value of False.
`?bool_value`: Template fields may be evaluated as boolean by appending "?" after the field name (and following "(path_sep)" or "[find/replace]". If a field is True (e.g. photo is HDR and field is `"{hdr}"`) or has any value, the value following the "?" will be used to render the template instead of the actual field value. If the template field evaluates to False (e.g. in above example, photo is not HDR) or has no value (e.g. photo has no title and field is `"{title}"`) then the default value following a "," will be used.
e.g. if photo is an HDR image,
- `"{hdr?ISHDR,NOTHDR}"` renders to `["ISHDR"]`
- `"{hdr?ISHDR,NOTHDR}"` renders to `"ISHDR"`
and if it is not an HDR image,
- `"{hdr?ISHDR,NOTHDR}"` renders to `["NOTHDR"]`
- `"{hdr?ISHDR,NOTHDR}"` renders to `"NOTHDR"`
Either or both `TRUE_VALUE` or `DEFAULT` (False value) may be empty which would result in empty string `[""]` when rendered.
`,DEFAULT`: optional default value to use if the template name has no value. This modifier is also used for the value if False for boolean-type fields (see above) as well as to hold a sub-template for values like `{created.strftime}`. If no default value provided, "_" is used. May also be provided in the `none_str` argument to `render_template()`. If provided both in the template string and in `none_str`, the value in the template string takes precedence.
`,default`: optional default value to use if the template name has no value. This modifier is also used for the value if False for boolean-type fields (see above) as well as to hold a sub-template for values like `{created.strftime}`. If no default value provided, "_" is used.
e.g., if photo has no title set,
- `"{title}"` renders to ["_"]
- `"{title,I have no title}"` renders to `["I have no title"]`
- `"{title}"` renders to "_"
- `"{title,I have no title}"` renders to `"I have no title"`
Template fields such as `created.strftime` use the DEFAULT value to pass the template to use for `strftime`.
Template fields such as `created.strftime` use the default value to pass the template to use for `strftime`.
e.g., if photo date is 4 February 2020, 19:07:38,
- `"{created.strftime,%Y-%m-%d-%H%M%S}"` renders to `["2020-02-04-190738"]`
- `"{created.strftime,%Y-%m-%d-%H%M%S}"` renders to `"2020-02-04-190738"`
Some template fields such as `"{media_type}"` use the `DEFAULT` value to allow customization of the output. For example, `"{media_type}"` resolves to the special media type of the photo such as `panorama` or `selfie`. You may use the `DEFAULT` value to override these in form: `"{media_type,video=vidéo;time_lapse=vidéo_accélérée}"`. In this example, if photo was a time_lapse photo, `media_type` would resolve to `vidéo_accélérée` instead of `time_lapse`.
Some template fields such as `"{media_type}"` use the default value to allow customization of the output. For example, `"{media_type}"` resolves to the special media type of the photo such as `panorama` or `selfie`. You may use the default value to override these in form: `"{media_type,video=vidéo;time_lapse=vidéo_accélérée}"`. In this example, if photo was a time_lapse photo, `media_type` would resolve to `vidéo_accélérée` instead of `time_lapse`.
Either or both bool_value or default (False value) may be empty which would result in empty string `""` when rendered.
If you want to include "{" or "}" in the output, use "{openbrace}" or "{closebrace}" template substitution.
e.g. `"{created.year}/{openbrace}{title}{closebrace}"` would result in `"2020/{Photo Title}"`.
<!-- OSXPHOTOS-TEMPLATE-HELP:END -->
See [Template Substitutions](#template-substitutions) for additional details.
@ -2508,6 +2574,15 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{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: ';'|
|{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: ']'|
|{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|
@ -2515,7 +2590,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{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.|
|{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).|
@ -2690,6 +2765,8 @@ For additional details about how osxphotos is implemented or if you would like t
- [wurlitzer](https://pypi.org/project/wurlitzer/)
- [toml](https://github.com/uiri/toml)
- [PhotoScript](https://github.com/RhetTbull/PhotoScript)
- [Rich](https://github.com/willmcgugan/rich)
- [textx](https://github.com/textX/textX)
## Acknowledgements

View File

@ -1,4 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 657c3de477547cc2058cc24ebb377071
config: d0470550c1fa9feae481cebbbbc126af
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview: module code &#8212; osxphotos 0.40.16 documentation</title>
<title>Overview: module code &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../" src="../_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photoinfo._photoinfo_exifinfo &#8212; osxphotos 0.40.16 documentation</title>
<title>osxphotos.photoinfo._photoinfo_exifinfo &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="../../../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../../../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../../../" src="../../../_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photoinfo._photoinfo_export &#8212; osxphotos 0.40.16 documentation</title>
<title>osxphotos.photoinfo._photoinfo_export &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="../../../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../../../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../../../" src="../../../_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photoinfo._photoinfo_scoreinfo &#8212; osxphotos 0.40.16 documentation</title>
<title>osxphotos.photoinfo._photoinfo_scoreinfo &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="../../../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../../../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../../../" src="../../../_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photoinfo._photoinfo_searchinfo &#8212; osxphotos 0.40.16 documentation</title>
<title>osxphotos.photoinfo._photoinfo_searchinfo &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="../../../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../../../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../../../" src="../../../_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photoinfo.photoinfo &#8212; osxphotos 0.40.16 documentation</title>
<title>osxphotos.photoinfo.photoinfo &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="../../../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../../../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../../../" src="../../../_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photosdb.photosdb &#8212; osxphotos 0.40.16 documentation</title>
<title>osxphotos.photosdb.photosdb &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="../../../_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="../../../_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="../../../" src="../../../_static/documentation_options.js"></script>

View File

@ -1,6 +1,6 @@
var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '0.40.16',
VERSION: '0.41.0',
LANGUAGE: 'None',
COLLAPSE_INDEX: false,
BUILDER: 'html',

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.40.16 documentation</title>
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; osxphotos 0.40.16 documentation</title>
<title>Index &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.40.16 documentation</title>
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos &#8212; osxphotos 0.40.16 documentation</title>
<title>osxphotos &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

Binary file not shown.

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos package &#8212; osxphotos 0.40.16 documentation</title>
<title>osxphotos package &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>

View File

@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.40.16 documentation</title>
<title>Search &#8212; osxphotos 0.41.0 documentation</title>
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />

View File

@ -8,7 +8,7 @@ import importlib
pathex = os.getcwd()
# include necessary data files
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates'), ('osxphotos/templates/xmp_sidecar_beta.mako', 'osxphotos/templates')]
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates'), ('osxphotos/templates/xmp_sidecar_beta.mako', 'osxphotos/templates'), ('osxphotos/phototemplate.tx', 'osxphotos'), ('osxphotos/phototemplate.md', 'osxphotos')]
package_imports = [['photoscript', ['photoscript.applescript']]]
for package, files in package_imports:
proot = os.path.dirname(importlib.import_module(package).__file__)

View File

@ -1,3 +1,3 @@
""" version info """
__version__ = "0.40.19"
__version__ = "0.41.0"

View File

@ -236,7 +236,7 @@ def query_options(f):
default=None,
multiple=False,
help="Search for photos with UUID(s) loaded from FILE. "
"Format is a single UUID per line. Lines preceeded with # are ignored.",
"Format is a single UUID per line. Lines preceded with # are ignored.",
type=click.Path(exists=True),
),
o(
@ -660,8 +660,16 @@ def cli(ctx, db, json_, debug):
"the full path to the folder and album photo is contained in as a keyword when exporting "
'you could specify --keyword-template "{folder_album}" '
'You may specify more than one template, for example --keyword-template "{folder_album}" '
'--keyword-template "{created.year}" '
"See Templating System below.",
'--keyword-template "{created.year}". '
"See '--replace-keywords' and Templating System below.",
)
@click.option(
"--replace-keywords",
is_flag=True,
help="Replace keywords with any values specified with --keyword-template. "
"By default, --keyword-template will add keywords to any keywords already associated "
"with the photo. If --replace-keywords is specified, values from --keyword-template "
"will replace any existing keywords instead of adding additional keywords.",
)
@click.option(
"--description-template",
@ -863,6 +871,7 @@ def export(
person_keyword,
album_keyword,
keyword_template,
replace_keywords,
description_template,
finder_tag_template,
finder_tag_keywords,
@ -1008,6 +1017,7 @@ def export(
person_keyword = cfg.person_keyword
album_keyword = cfg.album_keyword
keyword_template = cfg.keyword_template
replace_keywords = cfg.replace_keywords
description_template = cfg.description_template
finder_tag_template = cfg.finder_tag_template
finder_tag_keywords = cfg.finder_tag_keywords
@ -1434,6 +1444,7 @@ def export(
exiftool_option=exiftool_option,
strip=strip,
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
)
results += export_results
@ -2270,6 +2281,7 @@ def export_photo(
exiftool_option=None,
strip=False,
jpeg_ext=None,
replace_keywords=False,
):
"""Helper function for export that does the actual export
@ -2308,6 +2320,7 @@ def export_photo(
exiftool_merge_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
exiftool_merge_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
jpeg_ext: if not None, specify the extension to use for all JPEG images on export
replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
Returns:
list of path(s) of exported photo or None if photo was missing
@ -2371,15 +2384,15 @@ def export_photo(
rendered_suffix, unmatched = photo.render_template(
original_suffix, filename=True, strip=strip
)
except ValueError:
except ValueError as e:
raise click.BadOptionUsage(
"original_suffix",
f"Invalid template for --original-suffix '{original_suffix}'",
f"Invalid template for --original-suffix '{original_suffix}': {e}",
)
if not rendered_suffix or unmatched:
raise click.BadOptionUsage(
"original_suffix",
f"Invalid template for --original-suffix '{original_suffix}': results={rendered_suffix} unmatched={unmatched}",
f"Invalid template for --original-suffix '{original_suffix}': results={rendered_suffix} unknown field={unmatched}",
)
if len(rendered_suffix) > 1:
raise click.BadOptionUsage(
@ -2488,6 +2501,7 @@ def export_photo(
verbose=verbose_,
exiftool_flags=exiftool_option,
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
)
results += export_results
for warning_ in export_results.exiftool_warning:
@ -2559,15 +2573,15 @@ def export_photo(
rendered_suffix, unmatched = photo.render_template(
edited_suffix, filename=True, strip=strip
)
except ValueError:
except ValueError as e:
raise click.BadOptionUsage(
"edited_suffix",
f"Invalid template for --edited-suffix '{edited_suffix}'",
f"Invalid template for --edited-suffix '{edited_suffix}': {e}",
)
if not rendered_suffix or unmatched:
raise click.BadOptionUsage(
"edited_suffix",
f"Invalid template for --edited-suffix '{edited_suffix}': results={rendered_suffix} unmatched={unmatched}",
f"Invalid template for --edited-suffix '{edited_suffix}': unknown field={unmatched}",
)
if len(rendered_suffix) > 1:
raise click.BadOptionUsage(
@ -2635,6 +2649,7 @@ def export_photo(
verbose=verbose_,
exiftool_flags=exiftool_option,
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
)
results += export_results_edited
for warning_ in export_results_edited.exiftool_warning:
@ -2706,14 +2721,14 @@ def get_filenames_from_template(photo, filename_template, original_name, strip=F
filenames, unmatched = photo.render_template(
filename_template, path_sep="_", filename=True, strip=strip
)
except ValueError:
except ValueError as e:
raise click.BadOptionUsage(
"filename_template", f"Invalid template '{filename_template}'"
"filename_template", f"Invalid template '{filename_template}': {e}"
)
if not filenames or unmatched:
raise click.BadOptionUsage(
"filename_template",
f"Invalid template '{filename_template}': results={filenames} unmatched={unmatched}",
f"Invalid template '{filename_template}': unknown field={unmatched}",
)
filenames = [f"{file_}{photo_ext}" for file_ in filenames]
else:
@ -2760,12 +2775,14 @@ def get_dirnames_from_template(
dirnames, unmatched = photo.render_template(
directory, dirname=True, strip=strip
)
except ValueError:
raise click.BadOptionUsage("directory", f"Invalid template '{directory}'")
except ValueError as e:
raise click.BadOptionUsage(
"directory", f"Invalid template '{directory}': {e}"
)
if not dirnames or unmatched:
raise click.BadOptionUsage(
"directory",
f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}",
f"Invalid template '{directory}': unknown field={unmatched}",
)
dest_paths = []
@ -3066,16 +3083,16 @@ def write_finder_tags(
path_sep="/",
strip=strip,
)
except ValueError:
except ValueError as e:
raise click.BadOptionUsage(
"finder_tag_template",
f"Invalid template for --finder-tag-template': {template_str}",
f"Invalid template for --finder-tag-template '{template_str}': {e}",
)
if unmatched:
click.echo(
click.style(
f"Warning: unmatched template substitution for template: {template_str} {unmatched}",
f"Warning: unknown field for template: {template_str} unknown field = {unmatched}",
fg=CLI_COLOR_WARNING,
),
err=True,
@ -3122,15 +3139,15 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
path_sep="/",
strip=strip,
)
except ValueError:
except ValueError as e:
raise click.BadOptionUsage(
"xattr_template",
f"Invalid template for --xattr-template': {template_str}",
f"Invalid template for --xattr-template '{template_str}': {e}",
)
if unmatched:
click.echo(
click.style(
f"Warning: unmatched template substitution for template: {template_str} {unmatched}",
f"Warning: unmatched template substitution for template: {template_str} unknown field={unmatched}",
fg=CLI_COLOR_WARNING,
),
err=True,

View File

@ -1,14 +1,23 @@
"""Help text helper class for osxphotos CLI """
import io
import re
import click
import osxmetadata
from rich.console import Console
from rich.markdown import Markdown
from ._constants import (
EXTENDED_ATTRIBUTE_NAMES,
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
OSXPHOTOS_EXPORT_DB,
)
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
from .phototemplate import (
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
get_template_help,
)
class ExportCommand(click.Command):
@ -17,11 +26,11 @@ class ExportCommand(click.Command):
def get_help(self, ctx):
help_text = super().get_help(ctx)
formatter = click.HelpFormatter()
# passed to click.HelpFormatter.write_dl for formatting
formatter.write("\n\n")
formatter.write_text("** Export **")
formatter.write(rich_text("[bold]** Export **[/bold]", width=formatter.width))
formatter.write("\n")
formatter.write_text(
"When exporting photos, osxphotos creates a database in the top-level "
+ f"export folder called '{OSXPHOTOS_EXPORT_DB}'. This database preserves state information "
@ -56,7 +65,7 @@ class ExportCommand(click.Command):
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
)
formatter.write("\n\n")
formatter.write_text("** Extended Attributes **")
formatter.write(rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width))
formatter.write("\n")
formatter.write_text(
"""
@ -90,124 +99,9 @@ The following attributes may be used with '--xattr-template':
"For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys"
)
formatter.write("\n\n")
formatter.write_text("** Templating System **")
formatter.write(rich_text("[bold]** Templating System **[/bold]", width=formatter.width))
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'.
Some options supporting templates may be repeated e.g., --keyword-template
'{label}' --keyword-template '{media_type}' to add both labels and media
types to the keywords.
The general format for a template is '{TEMPLATE_FIELD,DEFAULT}'. The full template format is:
'{DELIM+TEMPLATE_FIELD(PATH_SEP)[OLD,NEW]?VALUE_IF_TRUE,DEFAULT}'
With a few exceptions (like '{created.strftime}') everything but the TEMPLATE_FIELD
is optional.
- 'DELIM+' Multi-value template fields such as '{keyword}' may be expanded 'in place'
with an optional delimiter using the template form '{DELIM+TEMPLATE_FIELD}'.
For example, a photo with keywords 'foo' and 'bar':
'{keyword}' renders to 'foo' and 'bar'
'{,+keyword}' renders to: 'foo,bar'
'{; +keyword}' renders to: 'foo; bar'
'{+keyword}' renders to 'foobar'
- 'TEMPLATE_FIELD' The name of the template field, for example 'keyword'
- '(PATH_SEP)' Some template fields such as '{folder_album}' are "path-like" in
that they join multiple elements into a single path-like string. For example,
if photo is in album Album1 in folder Folder1, '{folder_album}' results in
'Folder1/Album1'. This is so these template fields may be used as paths in
--directory. If you intend to use such a field as a string, e.g. in the
filename, you may specify a different path separator using the form:
'{TEMPLATE_FIELD(PATH_SEP)}'. For example, using the example above,
'{folder_album(-)}' would result in 'Folder1-Album1' and '{folder_album()}'
would result in 'Folder1Album1'.
- '[OLD,NEW]' Use the [OLD,NEW] option to replace text "OLD" in the template value
with text "NEW". For example, if you have album names with '/' in the album name you
could replace '/' with "-" using the template '{album[/,-]}'. This would replace
any occurence of "/" in the album name with "-"; album "Vacation/2019" would thus
become "Vacation-2019". You may specify more than one pair of OLD,NEW values by
listing them delimited by '|'. For example: '{album[/,-|:,-]}' to replace both
'/' and ':' by '-'. You can also use the [OLD,NEW] syntax to delete a character by
omitting the NEW value as in '{album[/,]}'.
- '?' Some template fields such as 'hdr' are boolean and resolve to True or False.
These take the form: '{TEMPLATE_FIELD?VALUE_IF_TRUE,VALUE_IF_FALSE}', e.g.
{hdr?is_hdr,not_hdr} which would result in 'is_hdr' if photo is an HDR image
and 'not_hdr' otherwise.
- ',DEFAULT' The ',' and DEFAULT value are optional. If TEMPLATE_FIELD results
in a null (empty) value, the template will result in default value of '_'.
You may specify an alternate default value by appending ',DEFAULT' after
template_field. Example: '{title,no_title}' would result in 'no_title' if the photo
had no title. Example: '{created.year}/{place.address,NO_ADDRESS}' but there was
no address associated with the photo, the resulting output would be:
'2020/NO_ADDRESS/photoname.jpg'. If specified, the default value may not
contain a brace symbol ('{' or '}').
Again, if you do not specify a default value and the template substitution has no
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
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 thus
effectively deleting the template from the resulting string.
You may include other text in the template string outside the {}
and use more than one template field in a single string,
e.g. '{created.year} - {created.month}' (e.g. '2020 - November').
Some templates may resolve to more than one value. For example, a photo can
have multiple keywords so '{keyword}' can result in multiple values. If used
in a filename or directory, these templates may result in more than one copy
of the photo being exported. For example, if photo has keywords "foo" and
"bar", --directory '{keyword}' will result in copies of the photo being
exported to 'foo/image_name.jpeg' and 'bar/image_name.jpeg'.
Some template fields such as '{media_type}' use the 'DEFAULT' value to allow
customization of the output. For example, '{media_type}' resolves to the
special media type of the photo such as 'panorama' or 'selfie'. You may use
the 'DEFAULT' value to override these in form:
'{media_type,video=vidéo;time_lapse=vidéo_accélérée}'. In this example, if
photo is a time_lapse photo, 'media_type' would resolve to 'vidéo_accélérée'
instead of 'time_lapse' and video would resolve to 'vidéo' if photo is an
ordinary video.
With the --directory and --filename options you may specify a template for the
export directory or filename, respectively. The directory will be appended to
the export path specified in the export DEST argument to export. For example,
if template is '{created.year}/{created.month}', and export destination DEST
is '/Users/maria/Pictures/export', the actual export directory for a photo
would be '/Users/maria/Pictures/export/2020/March' if the photo was created in
March 2020.
The templating system may also be used with the --keyword-template option to
set keywords on export (with --exiftool or --sidecar), for example, to set a
new keyword in format 'folder/subfolder/album' to preserve the folder/album
structure, you can use --keyword-template "{folder_album}"
In the template, valid template substitutions will be replaced by the
corresponding value from the table below. Invalid substitutions will result
in an error.
If you want the actual text of the template substition to appear in the
rendered name, use double braces, e.g. '{{' or '}}', thus using
'{created.year}/{{name}}' for --directory would result in output of
2020/{name}/photoname.jpg
"""
)
formatter.write(template_help(width=formatter.width))
formatter.write("\n")
formatter.write_text(
"With the --directory and --filename options you may specify a template for the "
@ -224,7 +118,8 @@ rendered name, use double braces, e.g. '{{' or '}}', thus using
"The templating system may also be used with the --keyword-template option "
+ "to set keywords on export (with --exiftool or --sidecar), "
+ "for example, to set a new keyword in format 'folder/subfolder/album' to "
+ 'preserve the folder/album structure, you can use --keyword-template "{folder_album}"'
+ 'preserve the folder/album structure, you can use --keyword-template "{folder_album}" '
+ "or in the 'folder>subfolder>album' format used in Lightroom Classic, --keyword-template \"{folder_album(>)}\"."
)
formatter.write("\n")
formatter.write_text(
@ -233,33 +128,7 @@ rendered name, use double braces, e.g. '{{' or '}}', thus using
+ "an error and the script will abort."
)
formatter.write("\n")
formatter.write_text(
"If you want the actual text of the template substition to appear "
+ "in the rendered name, use double braces, e.g. '{{' or '}}', thus "
+ "using '{created.year}/{{name}}' for --directory "
+ "would result in output of 2020/{name}/photoname.jpg"
)
formatter.write("\n")
formatter.write_text(
"You may specify an optional default value to use if the substitution does not contain a value "
+ "(e.g. the value is null) "
+ "by specifying the default value after a ',' in the template string: "
+ "for example, if template is '{created.year}/{place.address,NO_ADDRESS}' "
+ "but there was no address associated with the photo, the resulting output would be: "
+ "'2020/NO_ADDRESS/photoname.jpg'. "
+ "If specified, the default value may not contain a brace symbol ('{' or '}')."
)
formatter.write("\n")
formatter.write_text(
"If you do not specify a default value and the template substitution "
+ "has no 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 null."
)
formatter.write("\n")
formatter.write_text(
'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.'
)
formatter.write(rich_text("[bold]** Template Substitutions **[/bold]", width=formatter.width))
formatter.write("\n")
templ_tuples = [("Substitution", "Description")]
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
@ -284,3 +153,45 @@ rendered name, use double braces, e.g. '{{' or '}}', thus using
formatter.write_dl(templ_tuples)
help_text += formatter.getvalue()
return help_text
def template_help(width=78):
"""Return formatted string for template system """
sio = io.StringIO()
console = Console(file=sio, force_terminal=True, width=width)
template_help_md = strip_md_links(get_template_help())
console.print(Markdown(template_help_md))
help_str = sio.getvalue()
sio.close()
return help_str
def rich_text(text, width=78):
"""Return rich formatted text"""
sio = io.StringIO()
console = Console(file=sio, force_terminal=True, width=width)
console.print(text)
rich_text = sio.getvalue()
sio.close()
return rich_text
def strip_md_links(md):
"""strip markdown links from markdown text md
Args:
md: str, markdown text
Returns:
str with markdown links removed
Note: This uses a very basic regex that likely fails on all sorts of edge cases
but works for the links in the osxphotos docs
"""
links = r"(?:[*#])|\[(.*?)\]\(.+?\)"
def subfn(match):
return match.group(1)
return re.sub(links, subfn, md)

View File

@ -477,6 +477,7 @@ def export2(
jpeg_ext=None,
persons=True,
location=True,
replace_keywords=False,
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@ -531,6 +532,7 @@ def export2(
jpeg_ext: if set, will use this value for extension on jpegs converted to jpeg with convert_to_jpeg; if not set, uses jpeg; do not include the leading "."
persons: if True, include persons in exported metadata
location: if True, include location in exported metadata
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
Returns: ExportResults class
ExportResults has attributes:
@ -947,6 +949,7 @@ def export2(
filename=dest.name,
persons=persons,
location=location,
replace_keywords=replace_keywords
)
sidecars.append(
(
@ -972,6 +975,7 @@ def export2(
filename=dest.name,
persons=persons,
location=location,
replace_keywords=replace_keywords
)
sidecars.append(
(
@ -993,6 +997,7 @@ def export2(
extension=dest.suffix[1:] if dest.suffix else None,
persons=persons,
location=location,
replace_keywords=replace_keywords
)
sidecars.append(
(
@ -1062,6 +1067,7 @@ def export2(
merge_exif_persons=merge_exif_persons,
persons=persons,
location=location,
replace_keywords=replace_keywords
)
)[0]
if old_data != current_data:
@ -1084,6 +1090,7 @@ def export2(
merge_exif_persons=merge_exif_persons,
persons=persons,
location=location,
replace_keywords=replace_keywords
)
if warning_:
all_results.exiftool_warning.append((exported_file, warning_))
@ -1103,6 +1110,7 @@ def export2(
merge_exif_persons=merge_exif_persons,
persons=persons,
location=location,
replace_keywords=replace_keywords
),
)
export_db.set_stat_exif_for_file(
@ -1127,6 +1135,7 @@ def export2(
merge_exif_persons=merge_exif_persons,
persons=persons,
location=location,
replace_keywords=replace_keywords
)
if warning_:
all_results.exiftool_warning.append((exported_file, warning_))
@ -1146,6 +1155,7 @@ def export2(
merge_exif_persons=merge_exif_persons,
persons=persons,
location=location,
replace_keywords=replace_keywords
),
)
export_db.set_stat_exif_for_file(
@ -1367,6 +1377,7 @@ def _write_exif_data(
merge_exif_persons=False,
persons=True,
location=True,
replace_keywords=False,
):
"""write exif data to image file at filepath
@ -1379,6 +1390,7 @@ def _write_exif_data(
flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F)
persons: if True, write person data to metadata
location: if True, write location data to metadata
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
Returns:
(warning, error) of warning and error strings if exiftool produces warnings or errors
@ -1395,6 +1407,7 @@ def _write_exif_data(
merge_exif_persons=merge_exif_persons,
persons=persons,
location=location,
replace_keywords=replace_keywords,
)
with ExifTool(filepath, flags=flags, exiftool=self._db._exiftool_path) as exiftool:
@ -1419,6 +1432,7 @@ def _exiftool_dict(
filename=None,
persons=True,
location=True,
replace_keywords=False,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
@ -1434,6 +1448,7 @@ def _exiftool_dict(
merge_exif_persons: merge persons in the file's exif metadata (requires exiftool)
persons: if True, include person data
location: if True, include location data
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
Returns: dict with exiftool tags / values
@ -1496,7 +1511,7 @@ def _exiftool_dict(
if merge_exif_keywords:
keyword_list.extend(self._get_exif_keywords())
if self.keywords:
if self.keywords and not replace_keywords:
keyword_list.extend(self.keywords)
person_list = []
@ -1682,6 +1697,7 @@ def _exiftool_json_sidecar(
filename=None,
persons=True,
location=True,
replace_keywords=False,
):
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
Does not include all the EXIF fields as those are likely already in the image.
@ -1698,6 +1714,7 @@ def _exiftool_json_sidecar(
filename: filename of the destination image file for including in exiftool signature in JSON sidecar
persons: if True, include person data
location: if True, include location data
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
Returns: dict with exiftool tags / values
@ -1736,6 +1753,7 @@ def _exiftool_json_sidecar(
filename=filename,
persons=persons,
location=location,
replace_keywords=replace_keywords,
)
if not tag_groups:
@ -1760,6 +1778,7 @@ def _xmp_sidecar(
merge_exif_persons=False,
persons=True,
location=True,
replace_keywords=False,
):
"""returns string for XMP sidecar
use_albums_as_keywords: treat album names as keywords
@ -1771,6 +1790,7 @@ def _xmp_sidecar(
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
persons: if True, include person data
location: if True, include location data
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
"""
xmp_template_file = (
@ -1794,7 +1814,7 @@ def _xmp_sidecar(
if merge_exif_keywords:
keyword_list.extend(self._get_exif_keywords())
if self.keywords:
if self.keywords and not replace_keywords:
keyword_list.extend(self.keywords)
# TODO: keyword handling in this and _exiftool_json_sidecar is

View File

@ -0,0 +1,94 @@
The templating system converts one or template statements, written in osxphotos templating language, to one or more rendered values using information from the photo being processed.
In its simplest form, a template statement has the form: `"{template_field}"`, for example `"{title}"` which would resolve to the title of the photo.
Template statements may contain one or more modifiers. The full syntax is:
`"pretext{delim+template_field:subfield|filter(path_sep)[find,replace]?bool_value,default}posttext"`
Template statements are white-space sensitive meaning that white space (spaces, tabs) changes the meaning of the template statement.
`pretext` and `posttext` are free form text. For example, if a photo has title "My Photo Title". the template statement `"The title of the photo is {title}"`, resolves to `"The title of the photo is My Photo Title"`. The `pretext` in this example is `"The title if the photo is "` and the template_field is `{title}`.
`delim`: optional delimiter string to use when expanding multi-valued template values in-place
`+`: If present before template `name`, expands the template in place. If `delim` not provided, values are joined with no delimiter.
e.g. if Photo keywords are `["foo","bar"]`:
- `"{keyword}"` renders to `"foo", "bar"`
- `"{,+keyword}"` renders to: `"foo,bar"`
- `"{; +keyword}"` renders to: `"foo; bar"`
- `"{+keyword}"` renders to `"foobar"`
`template_field`: The template field to resolve. See [Template Substitutions](#template-substitutions) for full list of template fields.
`:subfield`: Some templates have sub-fields, For example, `{exiftool:IPTC:Make}`; the template_field is `exiftool` and the sub-field is `IPTC:Make`.
`|filter`: You may optionally append one or more filter commands to the end of the template field using the vertical pipe ('|') symbol. Filters may be combined, separated by '|' as in: `{keyword|capitalize|parens}`.
Valid filters are:
<!-- OSXPHOTOS-FILTER-TABLE:START - Do not remove or modify this section -->
- 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]'
<!-- OSXPHOTOS-FILTER-TABLE:END -->
e.g. if Photo keywords are `["FOO","bar"]`:
- `"{keyword|lower}"` renders to `"foo", "bar"`
- `"{keyword|upper}"` renders to: `"FOO", "BAR"`
- `"{keyword|capitalize}"` renders to: `"Foo", "Bar"`
- `"{keyword|lower|parens}"` renders to: `"(foo)", "(bar)"`
e.g. if Photo description is "my description":
- `"{descr|titlecase}"` renders to: `"My Description"`
`(path_sep)`: optional path separator to use when joining path-like fields, for example `{folder_album}`. Default is "/".
e.g. If Photo is in `Album1` in `Folder1`:
- `"{folder_album}"` renders to `["Folder1/Album1"]`
- `"{folder_album(>)}"` renders to `["Folder1>Album1"]`
- `"{folder_album()}"` renders to `["Folder1Album1"]`
`[find|replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair.
`?bool_value`: Template fields may be evaluated as boolean by appending "?" after the field name (and following "(path_sep)" or "[find/replace]". If a field is True (e.g. photo is HDR and field is `"{hdr}"`) or has any value, the value following the "?" will be used to render the template instead of the actual field value. If the template field evaluates to False (e.g. in above example, photo is not HDR) or has no value (e.g. photo has no title and field is `"{title}"`) then the default value following a "," will be used.
e.g. if photo is an HDR image,
- `"{hdr?ISHDR,NOTHDR}"` renders to `"ISHDR"`
and if it is not an HDR image,
- `"{hdr?ISHDR,NOTHDR}"` renders to `"NOTHDR"`
`,default`: optional default value to use if the template name has no value. This modifier is also used for the value if False for boolean-type fields (see above) as well as to hold a sub-template for values like `{created.strftime}`. If no default value provided, "_" is used.
e.g., if photo has no title set,
- `"{title}"` renders to "_"
- `"{title,I have no title}"` renders to `"I have no title"`
Template fields such as `created.strftime` use the default value to pass the template to use for `strftime`.
e.g., if photo date is 4 February 2020, 19:07:38,
- `"{created.strftime,%Y-%m-%d-%H%M%S}"` renders to `"2020-02-04-190738"`
Some template fields such as `"{media_type}"` use the default value to allow customization of the output. For example, `"{media_type}"` resolves to the special media type of the photo such as `panorama` or `selfie`. You may use the default value to override these in form: `"{media_type,video=vidéo;time_lapse=vidéo_accélérée}"`. In this example, if photo was a time_lapse photo, `media_type` would resolve to `vidéo_accélérée` instead of `time_lapse`.
Either or both bool_value or default (False value) may be empty which would result in empty string `""` when rendered.
If you want to include "{" or "}" in the output, use "{openbrace}" or "{closebrace}" template substitution.
e.g. `"{created.year}/{openbrace}{title}{closebrace}"` would result in `"2020/{Photo Title}"`.

View File

@ -1,20 +1,11 @@
""" Custom template system for osxphotos (implemented in PhotoInfo.render_template) """
# Rolled my own template system because:
# 1. Needed to handle multiple values (e.g. album, keyword)
# 2. Needed to handle default values if template not found
# 3. Didn't want user to need to know python (e.g. by using Mako which is
# already used elsewhere in this project)
#
# This code isn't elegant and is prime for refactoring but it seems to work well. PRs gladly accepted.
""" Custom template system for osxphotos, implements osxphotos template language (OTL) """
import datetime
import locale
import os
import pathlib
import re
from functools import partial
from textx import TextXSyntaxError, metamodel_from_file
from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter
@ -24,6 +15,10 @@ from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
# 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 = {
@ -122,6 +117,15 @@ TEMPLATE_SUBSTITUTIONS = {
"{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: ';'",
"{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: ']'",
}
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
@ -133,7 +137,7 @@ 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. "
"{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.",
@ -143,35 +147,71 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{searchinfo.venue_type}": "Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).",
}
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]'",
}
# 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
]
# regular expressions for matching template syntax
RE_OPENING_BRACE = r"(?<!\{)\{" # match { but not {{
RE_DELIM = r"([^}]*\+)?" # group 1: optional DELIM+
RE_FIELD_NAME = r"([^\\,}+\?]+)" # group 2: field name
RE_PATH_SEP = r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
# + r"(\[[^{}\)]*\])?" # group 4: optional [REPLACE]
RE_REPLACE = r"(\[[^{}]*\])?" # group 4: optional [REPLACE]
RE_BOOL_VAL = r"(\?[^\\,}]*)?" # group 5: optional ?TRUE_VALUE for boolean fields
RE_DEFAULT_VAL = r"(,[\w\=\;\-\%. ]*)?" # group 6: optional ,DEFAULT
RE_CLOSING_BRACE = r"(?=\}(?!\}))\}" # match } but not }}
MATCH_GROUPS_TOTAL = 6
MATCH_GROUPS_DELIM = 1
MATCH_GROUPS_FIELD = 2
MATCH_GROUPS_PATH_SEP = 3
MATCH_GROUPS_REPLACE = 4
MATCH_GROUPS_BOOL_VAL = 5
MATCH_GROUPS_DEFAULT = 6
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": "]",
}
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 """
@ -190,61 +230,8 @@ class PhotoTemplate:
# gets initialized in get_template_value
self.today = None
def make_subst_function(self, none_str, filename, dirname, 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
)
# closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups != MATCH_GROUPS_TOTAL:
raise ValueError(
f"Unexpected number of groups: expected {MATCH_GROUPS_TOTAL}, got {groups}"
)
delim = matchobj.group(MATCH_GROUPS_DELIM)
field = matchobj.group(MATCH_GROUPS_FIELD)
path_sep = matchobj.group(MATCH_GROUPS_PATH_SEP)
replace = matchobj.group(MATCH_GROUPS_REPLACE)
bool_val = matchobj.group(MATCH_GROUPS_BOOL_VAL)
default = matchobj.group(MATCH_GROUPS_DEFAULT)
# 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 [] from replace
replace = replace[1:-1] if replace 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, replacement=replace
)
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
# get parser singleton
self.parser = PhotoTemplateParser()
def render(
self,
@ -281,89 +268,67 @@ class PhotoTemplate:
if inplace_sep is None:
inplace_sep = INPLACE_DEFAULT
# 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
# phase 2: loop through all the multi-value template substitutions
# could result in multiple strings
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
# there would be 6 possible renderings (2 albums x 3 persons)
# regex to find {template_field,optional_default} in strings
# pylint: disable=anomalous-backslash-in-string
regex = (
RE_OPENING_BRACE
+ RE_DELIM
+ RE_FIELD_NAME
+ RE_PATH_SEP
+ RE_REPLACE
+ RE_BOOL_VAL
+ RE_DEFAULT_VAL
+ RE_CLOSING_BRACE
)
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
subst_func = self.make_subst_function(none_str, filename, dirname)
try:
model = self.parser.parse(template)
except TextXSyntaxError as e:
raise ValueError(f"SyntaxError: {e}")
# do the replacements
rendered = re.sub(regex, subst_func, template)
if not model:
# empty string
return [], []
# do multi-valued placements
# start with the single string from phase 1 above then loop through all
# multi-valued fields and all values for each of those fields
# rendered_strings will be updated as each field is processed
# for example: if two albums, two keywords, and one person and template is:
# "{created.year}/{album}/{keyword}/{person}"
# rendered strings would do the following:
# start (created.year filled in phase 1)
# ['2011/{album}/{keyword}/{person}']
# after processing albums:
# ['2011/Album1/{keyword}/{person}',
# '2011/Album2/{keyword}/{person}',]
# after processing keywords:
# ['2011/Album1/keyword1/{person}',
# '2011/Album1/keyword2/{person}',
# '2011/Album2/keyword1/{person}',
# '2011/Album2/keyword2/{person}',]
# after processing person:
# ['2011/Album1/keyword1/person1',
# '2011/Album1/keyword2/person1',
# '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',]
rendered_strings = self._render_multi_valued_templates(
rendered, none_str, path_sep, expand_inplace, inplace_sep, filename, dirname
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,
)
# process exiftool: templates
rendered_strings = self._render_exiftool_template(
rendered_strings,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
)
# find any {fields} that weren't replaced
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 rendered_str in rendered_strings:
unmatched.extend(
[
no_match[1]
for no_match in re.findall(regex, rendered_str)
if no_match[1] not in 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,
)
# fix any escaped curly braces
rendered_strings = [
rendered_str.replace("{{", "{").replace("}}", "}")
for rendered_str in rendered_strings
]
# process find/replace
if ts.template and ts.template.findreplace:
new_results = []
for result in results:
for pair in ts.template.findreplace.pairs:
find = pair.find or ""
repl = pair.replace or ""
result = result.replace(find, repl)
new_results.append(result)
results = new_results
rendered_strings = results
if filename:
rendered_strings = [
@ -377,268 +342,149 @@ class PhotoTemplate:
return rendered_strings, unmatched
def _render_multi_valued_templates(
def _render_template_string(
self,
rendered,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
ts,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
results=None,
unmatched=None,
):
rendered_strings = [rendered]
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed
re_str = (
RE_OPENING_BRACE
+ RE_DELIM
+ r"("
+ field # group 2: field name
+ r")"
+ RE_PATH_SEP
+ RE_REPLACE
+ RE_BOOL_VAL
+ RE_DEFAULT_VAL
+ RE_CLOSING_BRACE
"""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:
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 = []
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,
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
path_sep = (
matches.group(MATCH_GROUPS_PATH_SEP).strip("()")
if matches.group(MATCH_GROUPS_PATH_SEP) is not None
else path_sep
)
replace = (
matches.group(MATCH_GROUPS_REPLACE)[1:-1]
if matches.group(MATCH_GROUPS_REPLACE) is not None
else None
)
values = self.get_template_value_multi(
field,
path_sep,
filename=filename,
dirname=dirname,
replacement=replace,
)
if (
expand_inplace
or matches.group(MATCH_GROUPS_DELIM) is not None
):
delim = (
matches.group(MATCH_GROUPS_DELIM)[:-1]
if matches.group(MATCH_GROUPS_DELIM) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = (
delim.join(sorted(values))
if values and values[0]
else None
)
def lookup_template_value_multi(
lookup_value, *args, **kwargs
):
""" 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
_ 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 = self.make_subst_function(
none_str,
filename,
dirname,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = list({new_string})
else:
# create a new template string for each value
for val in values:
def lookup_template_value_multi(
lookup_value, *args, **kwargs
):
""" 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
_ 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 = self.make_subst_function(
none_str,
filename,
dirname,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
def _render_exiftool_template(
self,
rendered_strings,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
):
# TODO: lots of code commonality with render_multi_valued_templates -- combine or pull out
# TODO: put these in globals
if path_sep is None:
path_sep = os.path.sep
if inplace_sep is None:
inplace_sep = ","
# Build a regex that matches only the field being processed
re_str = (
RE_OPENING_BRACE
+ RE_DELIM
+ r"(exiftool:[^\\,}+\?\[\]]+)" # group 3 field name
+ RE_PATH_SEP
+ RE_REPLACE
+ RE_BOOL_VAL
+ RE_DEFAULT_VAL
+ RE_CLOSING_BRACE
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
# allmatches = regex_multi.finditer(str_template)
# for matches in allmatches:
path_sep = (
matches.group(MATCH_GROUPS_PATH_SEP).strip("()")
if matches.group(MATCH_GROUPS_PATH_SEP) is not None
else path_sep
elif field == "exiftool":
if subfield is None:
raise ValueError(
"SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'"
)
replace = (
matches.group(MATCH_GROUPS_REPLACE)[1:-1]
if matches.group(MATCH_GROUPS_REPLACE) is not None
else None
)
field = matches.group(MATCH_GROUPS_FIELD)
subfield = field[9:]
if not self.photo.path:
values = [None]
else:
exif = ExifTool(self.photo.path, exiftool=self.exiftool_path)
exifdict = exif.asdict()
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
subfield = subfield.lower()
if subfield in exifdict:
values = exifdict[subfield]
values = (
[values] if not isinstance(values, list) else values
)
if replace and values:
new_values = []
for value in values:
new_values.append(self.replace(value, replace))
values = new_values
vals = self.get_template_value_exiftool(
subfield, filename=filename, dirname=dirname
)
elif field in MULTI_VALUE_SUBSTITUTIONS:
vals = self.get_template_value_multi(
field, path_sep=path_sep, filename=filename, dirname=dirname
)
else:
unmatched.append(field)
return [], unmatched
# 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]
vals = [val for val in vals if val is not None]
else:
values = [None]
if expand_inplace or matches.group(MATCH_GROUPS_DELIM) is not None:
delim = (
matches.group(MATCH_GROUPS_DELIM)[:-1]
if matches.group(MATCH_GROUPS_DELIM) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = (
delim.join(sorted(values)) if values and values[0] else None
)
if is_bool:
if not vals:
vals = default
else:
vals = bool_val
elif not vals:
vals = default or [none_str]
def lookup_template_value_exif(lookup_value, *args, **kwargs):
""" 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
_ 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}")
if expand_inplace or delim is not None:
sep = delim if delim is not None else inplace_sep
vals = [sep.join(sorted(vals))]
subst = self.make_subst_function(
none_str,
filename,
dirname,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = list({new_string})
else:
# create a new template string for each value
for val in values:
for filter_ in filters:
vals = self.get_template_value_filter(filter_, vals)
def lookup_template_value_exif(
lookup_value, *args, **kwargs
):
""" 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
_ 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}"
)
pre = ts.pre or ""
post = ts.post or ""
subst = self.make_subst_function(
none_str,
filename,
dirname,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
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,
@ -649,7 +495,6 @@ class PhotoTemplate:
path_sep=None,
filename=False,
dirname=False,
replacement=None,
):
"""lookup value for template field (single-value template substitutions)
@ -661,7 +506,6 @@ class PhotoTemplate:
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
replacement: str, value to replace any illegal file path characters with; default = ":"
Returns:
The matching template value (which may be None).
@ -669,6 +513,8 @@ class PhotoTemplate:
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:
@ -690,9 +536,9 @@ class PhotoTemplate:
elif field == "photo_or_video":
value = self.get_photo_video_type(default)
elif field == "hdr":
value = self.get_photo_bool_attribute("hdr", default, bool_val)
value = "hdr" if self.photo.hdr else None
elif field == "edited":
value = self.get_photo_bool_attribute("hasadjustments", default, bool_val)
value = "edited" if self.photo.hasadjustments else None
elif field == "created.date":
value = DateTimeFormatter(self.photo.date).date
elif field == "created.year":
@ -720,7 +566,7 @@ class PhotoTemplate:
elif field == "created.strftime":
if default:
try:
value = self.photo.date.strftime(default)
value = self.photo.date.strftime(default[0])
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
@ -801,7 +647,7 @@ class PhotoTemplate:
if default:
try:
date = self.photo.date_modified or self.photo.date
value = date.strftime(default)
value = date.strftime(default[0])
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
@ -833,7 +679,7 @@ class PhotoTemplate:
elif field == "today.strftime":
if default:
try:
value = self.today.strftime(default)
value = self.today.strftime(default[0])
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
@ -918,49 +764,65 @@ class PhotoTemplate:
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 value and replacement:
value = self.replace(value, replacement)
# process character replacements
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()]
elif filter_ == "upper":
if values and type(values) == list:
value = [v.upper() for v in values]
else:
value = [values.upper()]
elif filter_ == "strip":
if values and type(values) == list:
value = [v.strip() for v in values]
else:
value = [values.strip()]
elif filter_ == "capitalize":
if values and type(values) == list:
value = [v.capitalize() for v in values]
else:
value = [values.capitalize()]
elif filter_ == "titlecase":
if values and type(values) == list:
value = [v.title() for v in values]
else:
value = [values.title()]
elif filter_ == "braces":
if values and type(values) == list:
value = ["{" + v + "}" for v in values]
else:
value = ["{" + values + "}"]
elif filter_ == "parens":
if values and type(values) == list:
value = ["(" + v + ")" for v in values]
else:
value = ["(" + values + ")"]
elif filter_ == "brackets":
if values and type(values) == list:
value = ["[" + v + "]" for v in values]
else:
value = ["[" + values + "]"]
else:
value = []
return value
def replace(self, value, replacement):
""" process REPLACE template option
Args:
value: str value to process
replacement: str in form OLD,NEW|OLD,NEW... with old and new values for replacement
Returns:
value with all replacements done
Raises:
ValueError if replacement string is in wrong format
"""
if not value:
return value
replacements = replacement.split("|")
for r in replacements:
try:
old, new = r.split(",")
except ValueError:
raise ValueError(f"Invalid template REPLACE value: {replacement}")
value = value.replace(old, new)
return value
def get_template_value_multi(
self, field, path_sep, filename=False, dirname=False, replacement=None
):
def get_template_value_multi(self, field, path_sep, filename=False, dirname=False):
"""lookup value for template field (multi-value template substitutions)
Args:
@ -969,7 +831,7 @@ class PhotoTemplate:
dirname: if True, values will be sanitized to be valid directory names; default = False
Returns:
List of the matching template values or [None].
List of the matching template values or [].
Raises:
ValueError if no rule exists for field.
@ -1025,18 +887,9 @@ class PhotoTemplate:
values = (
self.photo.search_info.venue_types if self.photo.search_info else []
)
elif not field.startswith("exiftool:"):
# exiftool: templates handled by _render_exiftool_template
else:
raise ValueError(f"Unhandled template value: {field}")
# do any replacements needs
if replacement:
new_values = []
for value in values:
# process replacements
new_values.append(self.replace(value, replacement))
values = new_values
# sanitize directory names if needed, folder_album handled differently above
if filename:
values = [sanitize_pathpart(value) for value in values]
@ -1044,8 +897,32 @@ class PhotoTemplate:
# 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 substite none_str for None
values = values or [None]
# 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 = ExifTool(self.photo.path, exiftool=self.exiftool_path)
exifdict = exif.asdict()
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
subfield = subfield.lower()
if subfield in exifdict:
values = exifdict[subfield]
values = [values] if not isinstance(values, list) else 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_photo_video_type(self, default):
@ -1103,7 +980,7 @@ def parse_default_kv(default, default_dict):
default_dict_ = default_dict.copy()
if default:
defaults = default.split(";")
defaults = default[0].split(";")
for kv in defaults:
try:
k, v = kv.split("=")
@ -1113,3 +990,12 @@ def parse_default_kv(default, default_dict):
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

122
osxphotos/phototemplate.tx Normal file
View File

@ -0,0 +1,122 @@
// OSXPhotos Template Language (OTL)
// a TemplateString has format:
// pre{delim+template_field:subfield|filter(path_sep)[find,replace]?bool_value,default}post
// a TemplateStatement may contain zero or more TemplateStrings
// The pre and post are optional strings
// The template itself (inside the {}) is also optional but if present
// everything but template_field is also optional
Statement:
(template_strings+=TemplateString)?
;
TemplateString:
pre=NON_TEMPLATE_STRING?
template=Template?
post=NON_TEMPLATE_STRING?
;
Template:
(
"{"
delim=Delim
field=Field
subfield=SubField
filter=Filter
pathsep=PathSep
findreplace=FindReplace
bool=Boolean
default=Default
"}"
)?
;
NON_TEMPLATE_STRING:
/[^\{\},]*/
;
Delim:
(
(value=DELIM_WORD)?
'+'
)?
;
DELIM_WORD:
/[^\{\}]*(?=\+\w)/
;
Field:
FIELD_WORD+
;
SubField:
(
":"-
SUBFIELD_WORD+
)?
;
FIELD_WORD:
/[\.\w]+/
;
SUBFIELD_WORD:
/[\.\w:]+/
;
Filter:
(
"|"-
(value+=FILTER_WORD['|'])?
)?
;
FILTER_WORD:
/[\.\w]+/
;
PathSep:
(
"("
(value=/[^\(\)\{\}]{0,1}/)?
")"
)?
;
FindReplace:
(
"["
(pairs+=FindReplacePair['|'])?
"]"
)?
;
FindReplacePair:
find=FIND_WORD
","
(replace=REPLACE_WORD)?
;
FIND_WORD:
/[^\[\]\|]*(?=\,)/
;
REPLACE_WORD:
/[^\[\]\|]*/
;
Boolean:
(
"?"
(value=Statement)?
)?
;
Default:
(
","
(value=Statement)?
)?
;

View File

@ -187,8 +187,10 @@ readme-renderer==25.0
regex==2020.2.20
requests==2.23.0
requests-toolbelt==0.9.1
rich==9.11.1
six==1.14.0
termcolor==1.1.0
textx==2.3.0
toml==0.10.0
tornado==6.0.4
tox==3.19.0

View File

@ -84,6 +84,8 @@ setup(
"photoscript>=0.1.0",
"toml>=0.10.0",
"osxmetadata>=0.99.13",
"textx==2.3.0",
"rich>=9.11.1",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

48
tests/photoinfo_mock.py Normal file
View File

@ -0,0 +1,48 @@
"""Selectively mock a PhotoInfo object"""
from osxphotos import PhotoInfo
class PhotoInfoMock(PhotoInfo):
def __init__(self, photo, **kwargs):
self._photo = photo
self._db = photo._db
self._info = photo._info
for kw in kwargs:
if hasattr(photo, kw):
setattr(self, f"_mock_{kw}", kwargs[kw])
else:
raise ValueError(f"Not a PhotoInfo attribute: {kw}")
@property
def hdr(self):
return (
self._mock_hdr
if getattr(self, "_mock_hdr", None) is not None
else self._photo.hdr
)
@property
def hasadjustments(self):
return (
self._mock_hasadjustments
if getattr(self, "_mock_hasadjustments", None) is not None
else self._photo.hasadjustments
)
@property
def keywords(self):
return (
self._mock_keywords
if getattr(self, "_mock_keywords", None) is not None
else self._photo.keywords
)
@property
def title(self):
return (
self._mock_title
if getattr(self, "_mock_title", None) is not None
else self._photo.title
)

File diff suppressed because one or more lines are too long

View File

@ -3,6 +3,8 @@ import pytest
import osxphotos
from osxphotos.exiftool import get_exiftool_path
from photoinfo_mock import PhotoInfoMock
try:
exiftool = get_exiftool_path()
except:
@ -45,15 +47,36 @@ UUID_MEDIA_TYPE = {
UUID_MULTI_KEYWORDS = "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"
TEMPLATE_VALUES_MULTI_KEYWORDS = {
"{keyword}": ["flowers", "wedding"],
"{keyword|parens}": ["(flowers)", "(wedding)"],
"{keyword|braces}": ["{flowers}", "{wedding}"],
"{keyword|brackets}": ["[flowers]", "[wedding]"],
"{keyword|parens|brackets|capitalize}": ["[(flowers)]", "[(wedding)]"],
"{keyword|capitalize|parens|brackets}": ["[(Flowers)]", "[(Wedding)]"],
"{keyword|upper}": ["FLOWERS", "WEDDING"],
"{keyword|lower}": ["flowers", "wedding"],
"{keyword|titlecase}": ["Flowers", "Wedding"],
"{keyword|capitalize}": ["Flowers", "Wedding"],
"{+keyword}": ["flowerswedding"],
"{+keyword|titlecase}": ["Flowerswedding"],
"{+keyword|capitalize}": ["Flowerswedding"],
"{;+keyword}": ["flowers;wedding"],
"{; +keyword}": ["flowers; wedding"],
"{; +keyword|titlecase}": ["Flowers; Wedding"],
"{; +keyword|titlecase|parens}": ["(Flowers; Wedding)"],
}
UUID_TITLE = "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4"
TEMPLATE_VALUES_TITLE = {
"{title}": ["Tulips tied together at a flower shop"],
"{title|titlecase}": ["Tulips Tied Together At A Flower Shop"],
"{title|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"],
"{title|titlecase|lower|upper}": ["TULIPS TIED TOGETHER AT A FLOWER SHOP"],
"{title|upper|titlecase}": ["Tulips Tied Together At A Flower Shop"],
"{title|capitalize}": ["Tulips tied together at a flower shop"],
"{title[ ,_]}": ["Tulips_tied_together_at_a_flower_shop"],
"{title[ ,_|e,]}": ["Tulips_tid_togthr_at_a_flowr_shop"],
"{title[ ,|e,]}": ["Tulipstidtogthrataflowrshop"],
"{title[e,]}": ["Tulips tid togthr at a flowr shop"],
"{+title}": ["Tulips tied together at a flower shop"],
"{,+title}": ["Tulips tied together at a flower shop"],
"{, +title}": ["Tulips tied together at a flower shop"],
@ -98,6 +121,14 @@ UUID_EXIFTOOL = {
"UK",
"United_Kingdom",
],
"{exiftool:IPTC:Keywords[ ,_|.,|L,]}": [
"England",
"ondon",
"ondon_2018",
"St_James's_Park",
"UK",
"United_Kingdom",
],
"{,+exiftool:IPTC:Keywords}": [
"England,London,London 2018,St. James's Park,UK,United Kingdom"
],
@ -140,6 +171,8 @@ TEMPLATE_VALUES = {
"{exif.camera_make}": "Apple",
"{exif.camera_model}": "iPhone 6s",
"{exif.lens_model}": "iPhone 6s back camera 4.15mm f/2.2",
"{album?{folder_album},{created.year}/{created.mm}}": "2020/02",
"{title?Title is '{title} - {descr}',No Title}": "Title is 'Glen Ord - Jack Rose Dining Saloon'",
}
@ -271,9 +304,10 @@ def test_lookup_multi(photosdb_places):
for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED:
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
if subst == "{exiftool}":
continue
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep)
assert isinstance(lookup, list)
assert len(lookup) >= 1
def test_subst(photosdb_places):
@ -382,19 +416,19 @@ def test_subst_unknown_val(photosdb_places):
template = "{created.year}/{foo}"
rendered, unknown = photo.render_template(template)
assert rendered[0] == "2020/{foo}"
# assert rendered[0] == "2020/{foo}"
assert unknown == ["foo"]
def test_subst_double_brace(photosdb_places):
""" Test substitution with double brace {{ which should be ignored """
# def test_subst_double_brace(photosdb_places):
# """ Test substitution with double brace {{ which should be ignored """
photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
# photo = photosdb_places.photos(uuid=[UUID_DICT["place_dc"]])[0]
template = "{created.year}/{{foo}}"
rendered, unknown = photo.render_template(template)
assert rendered[0] == "2020/{foo}"
assert not unknown
# template = "{created.year}/{{foo}}"
# rendered, unknown = photo.render_template(template)
# assert rendered[0] == "2020/{foo}"
# assert not unknown
def test_subst_unknown_val_with_default(photosdb_places):
@ -406,7 +440,7 @@ def test_subst_unknown_val_with_default(photosdb_places):
template = "{created.year}/{foo,bar}"
rendered, unknown = photo.render_template(template)
assert rendered[0] == "2020/{foo,bar}"
# assert rendered[0] == "2020/{foo,bar}"
assert unknown == ["foo"]
@ -496,16 +530,14 @@ def test_subst_multi_0_2_0_default_val_unknown_val(photosdb):
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
template = (
"{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person}/{foo}/{{baz}}"
)
template = "{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person}/{foo}/{baz}"
expected = [
"2019/NOALBUM/wedding/_/{foo}/{baz}",
"2019/NOALBUM/flowers/_/{foo}/{baz}",
]
rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected)
assert unknown == ["foo"]
# assert sorted(rendered) == sorted(expected)
assert unknown == ["foo", "baz"]
def test_subst_multi_0_2_0_default_val_unknown_val_2(photosdb):
@ -515,14 +547,14 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2(photosdb):
# one album, one keyword, two persons
photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0]
template = "{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person}/{foo,bar}/{{baz,bar}}"
template = "{created.year}/{album,NOALBUM}/{keyword,NOKEYWORD}/{person}/{foo,bar}/{baz,bar}"
expected = [
"2019/NOALBUM/wedding/_/{foo,bar}/{baz,bar}",
"2019/NOALBUM/flowers/_/{foo,bar}/{baz,bar}",
]
rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected)
assert unknown == ["foo"]
# assert sorted(rendered) == sorted(expected)
assert unknown == ["foo", "baz"]
def test_subst_multi_folder_albums_1(photosdb):
@ -557,6 +589,22 @@ def test_subst_multi_folder_albums_1_path_sep(photosdb):
assert unknown == []
def test_subst_multi_folder_albums_1_path_sep_lower(photosdb):
""" Test substitutions for folder_album are correct with custom PATH_SEP """
# photo in an album in a folder
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0]
template = "{folder_album|lower(:)}"
expected = [
"2018-10 - sponsion, museum, frühstück, römermuseum",
"2019-10/11 paris clermont",
"folder1:subfolder2:albuminfolder",
]
rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected)
assert unknown == []
def test_subst_multi_folder_albums_2(photosdb):
""" Test substitutions for folder_album are correct """
@ -599,8 +647,21 @@ def test_subst_multi_folder_albums_3_path_sep(photosdb_14_6):
# photo in an album in a folder
photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
template = "{folder_album(:)}"
expected = ["Folder1:SubFolder2:AlbumInFolder", "Pumpkin Farm", "Test Album (1)"]
template = "{folder_album(>)}"
expected = ["Folder1>SubFolder2>AlbumInFolder", "Pumpkin Farm", "Test Album (1)"]
rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected)
assert unknown == []
def test_subst_multi_folder_albums_4_path_sep_lower(photosdb_14_6):
""" Test substitutions for folder_album on < Photos 5 with custom PATH_SEP """
import osxphotos
# photo in an album in a folder
photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
template = "{folder_album|lower(>)}"
expected = ["folder1>subfolder2>albuminfolder", "pumpkin farm", "test album (1)"]
rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected)
assert unknown == []
@ -712,13 +773,13 @@ def test_partial_match(photosdb_cloud):
for uuid in COMMENT_UUID_DICT:
photo = photosdb_cloud.get_photo(uuid)
rendered, notmatched = photo.render_template("{keywords}")
assert [rendered, notmatched] == [["{keywords}"], ["keywords"]]
assert [rendered, notmatched] == [[], ["keywords"]]
rendered, notmatched = photo.render_template("{keywords,}")
assert [rendered, notmatched] == [["{keywords,}"], ["keywords"]]
assert [rendered, notmatched] == [[], ["keywords"]]
rendered, notmatched = photo.render_template("{keywords,foo}")
assert [rendered, notmatched] == [["{keywords,foo}"], ["keywords"]]
assert [rendered, notmatched] == [[], ["keywords"]]
rendered, notmatched = photo.render_template("{,+keywords,foo}")
assert [rendered, notmatched] == [["{,+keywords,foo}"], ["keywords"]]
assert [rendered, notmatched] == [[], ["keywords"]]
def test_expand_in_place_with_delim(photosdb):
@ -750,3 +811,57 @@ def test_exiftool_template(photosdb):
rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(UUID_EXIFTOOL[uuid][template])
def test_hdr(photosdb):
""" Test hdr """
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
photomock = PhotoInfoMock(photo, hdr="hdr")
rendered, _ = photomock.render_template("{hdr}")
assert rendered == ["hdr"]
def test_edited(photosdb):
""" Test edited """
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
photomock = PhotoInfoMock(photo, hasadjustments=True)
rendered, _ = photomock.render_template("{edited}")
assert rendered == ["edited"]
def test_nested_template_bool(photosdb):
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
template = "{hdr?{edited?HDR_EDITED,HDR_NOT_EDITED},{edited?NOT_HDR_EDITED,NOT_HDR_NOT_EDITED}}"
photomock = PhotoInfoMock(photo, hdr=True, hasadjustments=True)
rendered, _ = photomock.render_template(template)
assert rendered == ["HDR_EDITED"]
photomock = PhotoInfoMock(photo, hdr=True, hasadjustments=False)
rendered, _ = photomock.render_template(template)
assert rendered == ["HDR_NOT_EDITED"]
photomock = PhotoInfoMock(photo, hdr=False, hasadjustments=False)
rendered, _ = photomock.render_template(template)
assert rendered == ["NOT_HDR_NOT_EDITED"]
photomock = PhotoInfoMock(photo, hdr=False, hasadjustments=True)
rendered, _ = photomock.render_template(template)
assert rendered == ["NOT_HDR_EDITED"]
def test_nested_template(photosdb):
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
photomock = PhotoInfoMock(photo, keywords=[], title="My Title")
rendered, _ = photomock.render_template("{keyword,{title}}")
assert rendered == ["My Title"]
def test_punctuation(photosdb):
from osxphotos.phototemplate import PUNCTUATION
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
for punc in PUNCTUATION:
rendered, _ = photo.render_template("{" + punc + "}")
assert rendered[0] == PUNCTUATION[punc]

View File

@ -1,4 +1,6 @@
""" Automatically update certain sections of README.md for osxphotos """
""" Automatically update certain sections of README.md for osxphotos
Also updates osxphotos/phototemplate.md
"""
# This is a pretty "dumb" script that searches the README.md for
# certain tags, expressed as HTML comments, and replaces text between
@ -14,17 +16,31 @@ from osxphotos.cli import help
from osxphotos.phototemplate import (
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
FILTER_VALUES,
)
TEMPLATE_HELP = "osxphotos/phototemplate.md"
USAGE_START = (
"<!-- OSXPHOTOS-EXPORT-USAGE:START - Do not remove or modify this section -->"
)
USAGE_STOP = "<!-- OSXPHOTOS-EXPORT-USAGE:END -->"
TEMPLATE_TABLE_START = (
"<!-- OSXPHOTOS-TEMPLATE-TABLE:START - Do not remove or modify this section -->"
)
TEMPLATE_TABLE_STOP = "<!-- OSXPHOTOS-TEMPLATE-TABLE:END -->"
TEMPLATE_HELP_START = (
"<!-- OSXPHOTOS-TEMPLATE-HELP:START - Do not remove or modify this section -->"
)
TEMPLATE_HELP_STOP = "<!-- OSXPHOTOS-TEMPLATE-HELP:END -->"
TEMPLATE_FILTER_TABLE_START = (
"!-- OSXPHOTOS-FILTER-TABLE:START - Do not remove or modify this section -->"
)
TEMPLATE_FILTER_TABLE_STOP = "<!-- OSXPHOTOS-FILTER-TABLE:END -->"
def generate_template_table():
""" generate template substitution table for README.md """
@ -87,17 +103,35 @@ def replace_text(text, start_tag, stop_tag, replacement_text, prefix="", postfix
def main():
""" update README.md """
# update phototemplate.md with info on filters
print(f"Updating {TEMPLATE_HELP}")
filter_help = "\n".join(f"- {f}: {descr}" for f, descr in FILTER_VALUES.items())
with open(TEMPLATE_HELP) as file:
template_help = file.read()
with open("README.md", "r") as file:
readme = file.read()
template_help = replace_text(
template_help,
TEMPLATE_FILTER_TABLE_START,
TEMPLATE_FILTER_TABLE_STOP,
filter_help,
prefix="\n",
postfix="\n",
)
with open(TEMPLATE_HELP, "w") as file:
file.write(template_help)
# update the help text for `osxphotos help export`
print("Updating help for `osxphotos help export`")
with open("README.md", "r") as file:
readme = file.read()
help_txt = generate_help_text("export")
new_readme = replace_text(
readme, USAGE_START, USAGE_STOP, help_txt, prefix="\n```\n", postfix="\n```\n"
)
# update the template substitution table
print("Updating template substitution table")
template_table = generate_template_table()
new_readme = replace_text(
new_readme,
@ -108,6 +142,21 @@ def main():
postfix="\n",
)
# update the template system docs
print("Updating template system help")
with open(TEMPLATE_HELP) as fd:
template_help = fd.read()
new_readme = replace_text(
new_readme,
TEMPLATE_HELP_START,
TEMPLATE_HELP_STOP,
template_help,
prefix="\n",
postfix="\n",
)
print("Writing new README.md")
with open("README.md", "w") as file:
file.write(new_readme)