Updated template language to match autofile

* Added field_arg to template grammar

* Updated template grammar to match autofile MTL

* Added format and filter args from autofile

* Added cloud_metadata to PhotoInfo

* Version bump, updated docs

* Added tests for filters
This commit is contained in:
Rhet Turnbull 2022-05-27 18:33:07 -07:00 committed by GitHub
parent 999b16e80f
commit 0a973d67f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 417 additions and 176 deletions

View File

@ -5,16 +5,20 @@
""" """
import pathlib import pathlib
from typing import List, Union from typing import List, Optional, Union
import osxphotos from osxphotos import ExportOptions, PhotoInfo
def example(photo: osxphotos.PhotoInfo, **kwargs) -> Union[List, str]: def example(
""" example function for {function} template; adds suffix of # if photo has adjustments and ! if photo is a favorite photo: PhotoInfo, options: ExportOptions, args: Optional[str] = None, **kwargs
) -> Union[List, str]:
"""example function for {function} template; adds suffix of # if photo has adjustments and ! if photo is a favorite
Args: Args:
photo: osxphotos.PhotoInfo object photo: osxphotos.PhotoInfo object
options: osxphotos.ExportOptions object
args: optional str of arguments passed to template function
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function} **kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
Returns: Returns:

View File

@ -10,7 +10,7 @@ In its simplest form, a template statement has the form: `"{template_field}"`, f
Template statements may contain one or more modifiers. The full syntax is: Template statements may contain one or more modifiers. The full syntax is:
`"pretext{delim+template_field:subfield|filter(path_sep)[find,replace] conditional?bool_value,default}posttext"` `"pretext{delim+template_field:subfield(field_arg)|filter[find,replace] conditional?bool_value,default}posttext"`
Template statements are white-space sensitive meaning that white space (spaces, tabs) changes the meaning of the template statement. Template statements are white-space sensitive meaning that white space (spaces, tabs) changes the meaning of the template statement.
@ -31,6 +31,8 @@ e.g. if Photo keywords are `["foo","bar"]`:
`:subfield`: Some templates have sub-fields, For example, `{exiftool:IPTC:Make}`; the template_field is `exiftool` and the sub-field is `IPTC:Make`. `:subfield`: Some templates have sub-fields, For example, `{exiftool:IPTC:Make}`; the template_field is `exiftool` and the sub-field is `IPTC:Make`.
`(field_arg)`: optional arguments to pass to the field; for example, with `{folder_album}` this is used to pass the path separator used for joining folders and albums when rendering the field (default is "/" for `{folder_album}`).
`|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}`. `|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: Valid filters are:
@ -40,16 +42,6 @@ from osxphotos.phototemplate import FILTER_VALUES
filter_help = "\n".join(f"- `{f}`: {descr}" for f, descr in FILTER_VALUES.items()) filter_help = "\n".join(f"- `{f}`: {descr}" for f, descr in FILTER_VALUES.items())
cog.out(filter_help) cog.out(filter_help)
]]]--> ]]]-->
- `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]'
- `shell_quote`: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.
- `function`: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at <https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py>
<!--[[[end]]]--> <!--[[[end]]]-->
e.g. if Photo keywords are `["FOO","bar"]`: e.g. if Photo keywords are `["FOO","bar"]`:
@ -63,8 +55,6 @@ e.g. if Photo description is "my description":
- `"{descr|titlecase}"` renders to: `"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`: e.g. If Photo is in `Album1` in `Folder1`:
- `"{folder_album}"` renders to `["Folder1/Album1"]` - `"{folder_album}"` renders to `["Folder1/Album1"]`
@ -107,7 +97,7 @@ This can be used to rename files as well, for example:
This renames any photo that is a favorite as 'Favorite-ImageName.jpg' (where 'ImageName.jpg' is the original name of the photo) and all other photos with the unmodified original name. This renames any photo that is a favorite as 'Favorite-ImageName.jpg' (where 'ImageName.jpg' is the original name of the photo) and all other photos with the unmodified original name.
`?bool_value`: Template fields may be evaluated as boolean (True/False) 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. `?bool_value`: Template fields may be evaluated as boolean (True/False) by appending "?" after the field name (and following "(field_arg)" 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, e.g. if photo is an HDR image,
@ -137,3 +127,13 @@ Either or both bool_value or default (False value) may be empty which would resu
If you want to include "{" or "}" in the output, use "{openbrace}" or "{closebrace}" template substitution. 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}"`. e.g. `"{created.year}/{openbrace}{title}{closebrace}"` would result in `"2020/{Photo Title}"`.
**Variables**
You can define variables for later use in the template string using the format `{var:NAME,VALUE}`. Variables may then be referenced using the format `%NAME`. For example: `{var:foo,bar}` defines the variable `%foo` to have value `bar`. This can be useful if you want to re-use a complex template value in multiple places within your template string or for allowing the use of characters that would otherwise be prohibited in a template string. For example, the "pipe" (`|`) character is not allowed in a find/replace pair but you can get around this limitation like so: `{var:pipe,{pipe}}{title[-,%pipe]}` which replaces the `-` character with `|` (the value of `%pipe`).
Variables can also be referenced as fields in the template string, for example: `{var:year,created.year}{original_name}-{%year}`. In some cases, use of variables can make your template string more readable. Variables can be used as template fields, as values for filters, as values for conditional operations, or as default values. When used as a conditional value or default value, variables should be treated like any other field and enclosed in braces as conditional and default values are evaluated as template strings. For example: `{var:name,Katie}{person contains {%name}?{%name},Not-{%name}}`.
If you need to use a `%` (percent sign character), you can escape the percent sign by using `%%`. You can also use the `{percent}` template field where a template field is required. For example:
`{title[:,%%]}` replaces the `:` with `%` and `{title contains Foo?{title}{percent},{title}}` adds `%` to the title if it contains `Foo`.

View File

@ -6,7 +6,7 @@ In its simplest form, a template statement has the form: `"{template_field}"`, f
Template statements may contain one or more modifiers. The full syntax is: Template statements may contain one or more modifiers. The full syntax is:
`"pretext{delim+template_field:subfield|filter(path_sep)[find,replace] conditional?bool_value,default}posttext"` `"pretext{delim+template_field:subfield(field_arg)|filter[find,replace] conditional?bool_value,default}posttext"`
Template statements are white-space sensitive meaning that white space (spaces, tabs) changes the meaning of the template statement. Template statements are white-space sensitive meaning that white space (spaces, tabs) changes the meaning of the template statement.
@ -27,6 +27,8 @@ e.g. if Photo keywords are `["foo","bar"]`:
`:subfield`: Some templates have sub-fields, For example, `{exiftool:IPTC:Make}`; the template_field is `exiftool` and the sub-field is `IPTC:Make`. `:subfield`: Some templates have sub-fields, For example, `{exiftool:IPTC:Make}`; the template_field is `exiftool` and the sub-field is `IPTC:Make`.
`(field_arg)`: optional arguments to pass to the field; for example, with `{folder_album}` this is used to pass the path separator used for joining folders and albums when rendering the field (default is "/" for `{folder_album}`).
`|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}`. `|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: Valid filters are:
@ -41,6 +43,18 @@ Valid filters are:
- `brackets`: Enclose value in brackets, e.g. 'value' => '[value]' - `brackets`: Enclose value in brackets, e.g. 'value' => '[value]'
- `shell_quote`: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed. - `shell_quote`: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.
- `function`: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py - `function`: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py
- `split(x)`: Split value into a list of values using x as delimiter, e.g. 'value1;value2' => ['value1', 'value2'] if used with split(;).
- `autosplit`: Automatically split delimited string into separate values; will split strings delimited by comma, semicolon, or space, e.g. 'value1,value2' => ['value1', 'value2'].
- `chop(x)`: Remove x characters off the end of value, e.g. chop(1): 'Value' => 'Valu'; when applied to a list, chops characters from each list value, e.g. chop(1): ['travel', 'beach']=> ['trave', 'beac'].
- `chomp(x)`: Remove x characters from the beginning of value, e.g. chomp(1): ['Value'] => ['alue']; when applied to a list, removes characters from each list value, e.g. chomp(1): ['travel', 'beach']=> ['ravel', 'each'].
- `sort`: Sort list of values, e.g. ['c', 'b', 'a'] => ['a', 'b', 'c'].
- `rsort`: Sort list of values in reverse order, e.g. ['a', 'b', 'c'] => ['c', 'b', 'a'].
- `reverse`: Reverse order of values, e.g. ['a', 'b', 'c'] => ['c', 'b', 'a'].
- `uniq`: Remove duplicate values, e.g. ['a', 'b', 'c', 'b', 'a'] => ['a', 'b', 'c'].
- `join(x)`: Join list of values with delimiter x, e.g. join(:): ['a', 'b', 'c'] => 'a:b:c'; the DELIM option functions similar to join(x) but with DELIM, the join happens before being passed to any filters.
- `append(x)`: Append x to list of values, e.g. append(d): ['a', 'b', 'c'] => ['a', 'b', 'c', 'd'].
- `prepend(x)`: Prepend x to list of values, e.g. prepend(d): ['a', 'b', 'c'] => ['d', 'a', 'b', 'c'].
- `remove(x)`: Remove x from list of values, e.g. remove(b): ['a', 'b', 'c'] => ['a', 'c'].
e.g. if Photo keywords are `["FOO","bar"]`: e.g. if Photo keywords are `["FOO","bar"]`:
@ -53,8 +67,6 @@ e.g. if Photo description is "my description":
- `"{descr|titlecase}"` renders to: `"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`: e.g. If Photo is in `Album1` in `Folder1`:
- `"{folder_album}"` renders to `["Folder1/Album1"]` - `"{folder_album}"` renders to `["Folder1/Album1"]`
@ -97,7 +109,7 @@ This can be used to rename files as well, for example:
This renames any photo that is a favorite as 'Favorite-ImageName.jpg' (where 'ImageName.jpg' is the original name of the photo) and all other photos with the unmodified original name. This renames any photo that is a favorite as 'Favorite-ImageName.jpg' (where 'ImageName.jpg' is the original name of the photo) and all other photos with the unmodified original name.
`?bool_value`: Template fields may be evaluated as boolean (True/False) 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. `?bool_value`: Template fields may be evaluated as boolean (True/False) by appending "?" after the field name (and following "(field_arg)" 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, e.g. if photo is an HDR image,
@ -127,3 +139,13 @@ Either or both bool_value or default (False value) may be empty which would resu
If you want to include "{" or "}" in the output, use "{openbrace}" or "{closebrace}" template substitution. 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}"`. e.g. `"{created.year}/{openbrace}{title}{closebrace}"` would result in `"2020/{Photo Title}"`.
**Variables**
You can define variables for later use in the template string using the format `{var:NAME,VALUE}`. Variables may then be referenced using the format `%NAME`. For example: `{var:foo,bar}` defines the variable `%foo` to have value `bar`. This can be useful if you want to re-use a complex template value in multiple places within your template string or for allowing the use of characters that would otherwise be prohibited in a template string. For example, the "pipe" (`|`) character is not allowed in a find/replace pair but you can get around this limitation like so: `{var:pipe,{pipe}}{title[-,%pipe]}` which replaces the `-` character with `|` (the value of `%pipe`).
Variables can also be referenced as fields in the template string, for example: `{var:year,created.year}{original_name}-{%year}`. In some cases, use of variables can make your template string more readable. Variables can be used as template fields, as values for filters, as values for conditional operations, or as default values. When used as a conditional value or default value, variables should be treated like any other field and enclosed in braces as conditional and default values are evaluated as template strings. For example: `{var:name,Katie}{person contains {%name}?{%name},Not-{%name}}`.
If you need to use a `%` (percent sign character), you can escape the percent sign by using `%%`. You can also use the `{percent}` template field where a template field is required. For example:
`{title[:,%%]}` replaces the `:` with `%` and `{title contains Foo?{title}{percent},{title}}` adds `%` to the title if it contains `Foo`.

View File

@ -5,11 +5,12 @@ import datetime
import locale import locale
import os import os
import pathlib import pathlib
import re
import shlex import shlex
import sys import sys
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import List, Optional, Tuple
from textx import TextXSyntaxError, metamodel_from_file from textx import TextXSyntaxError, metamodel_from_file
@ -148,21 +149,23 @@ TEMPLATE_SUBSTITUTIONS = {
"{album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album. " "{album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album. "
+ "Only valid when used in a '--filename' template and only when '{album}' or '{folder_album}' is used in the '--directory' template. " + "Only valid when used in a '--filename' template and only when '{album}' or '{folder_album}' is used in the '--directory' template. "
+ 'For example \'--directory "{folder_album}" --filename "{album_seq}_{original_name}"\'. ' + 'For example \'--directory "{folder_album}" --filename "{album_seq}_{original_name}"\'. '
+ "To start counting at a value other than 0, append append a period and the starting value to the field name. " + "To start counting at a value other than 0, append append '(starting_value)' to the field name. "
+ "For example, to start counting at 1 instead of 0: '{album_seq.1}'. " + "For example, to start counting at 1 instead of 0: '{album_seq(1)}'. "
+ "May be formatted using a python string format code. " + "May be formatted using a python string format code. "
+ "For example, to format as a 5-digit integer and pad with zeros, use '{album_seq:05d}' which results in " + "For example, to format as a 5-digit integer and pad with zeros, use '{album_seq:05d}' which results in "
+ "00000, 00001, 00002...etc. " + "00000, 00001, 00002...etc. "
+ "To format while also using a starting value: '{album_seq:05d(1)}' which results in 0001, 00002...etc."
+ "This may result in incorrect sequences if you have duplicate albums with the same name; see also '{folder_album_seq}'.", + "This may result in incorrect sequences if you have duplicate albums with the same name; see also '{folder_album_seq}'.",
"{folder_album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album and folder path. " "{folder_album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album and folder path. "
+ "Only valid when used in a '--filename' template and only when '{folder_album}' is used in the '--directory' template. " + "Only valid when used in a '--filename' template and only when '{folder_album}' is used in the '--directory' template. "
+ 'For example \'--directory "{folder_album}" --filename "{folder_album_seq}_{original_name}"\'. ' + 'For example \'--directory "{folder_album}" --filename "{folder_album_seq}_{original_name}"\'. '
+ "To start counting at a value other than 0, append append a period and the starting value to the field name. " + "To start counting at a value other than 0, append '(starting_value)' to the field name. "
+ "For example, to start counting at 1 instead of 0: '{folder_album_seq.1}' " + "For example, to start counting at 1 instead of 0: '{folder_album_seq(1)}' "
+ "May be formatted using a python string format code. " + "May be formatted using a python string format code. "
+ "For example, to format as a 5-digit integer and pad with zeros, use '{folder_album_seq:05d}' which results in " + "For example, to format as a 5-digit integer and pad with zeros, use '{folder_album_seq:05d}' which results in "
+ "00000, 00001, 00002...etc. " + "00000, 00001, 00002...etc. "
+ "This may result in incorrect sequences if you have duplicate albums with the same name in the same folder; see also '{album_seq}'.", + "To format while also using a starting value: '{folder_album_seq:05d(1)}' which results in 0001, 00002...etc."
+ "This may result in incorrect sequences if you have duplicate albums with the same name in the same folder; see also '{album_seq}'. ",
"{comma}": "A comma: ','", "{comma}": "A comma: ','",
"{semicolon}": "A semicolon: ';'", "{semicolon}": "A semicolon: ';'",
"{questionmark}": "A question mark: '?'", "{questionmark}": "A question mark: '?'",
@ -222,6 +225,9 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
+ "Note: this feature is not the same thing as Live Text in macOS Monterey, which osxphotos does not yet support.", + "Note: this feature is not the same thing as Live Text in macOS Monterey, which osxphotos does not yet support.",
"{shell_quote}": "Use in form '{shell_quote,TEMPLATE}'; quotes the rendered TEMPLATE value(s) for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.", "{shell_quote}": "Use in form '{shell_quote,TEMPLATE}'; quotes the rendered TEMPLATE value(s) for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.",
"{strip}": "Use in form '{strip,TEMPLATE}'; strips whitespace from begining and end of rendered TEMPLATE value(s).", "{strip}": "Use in form '{strip,TEMPLATE}'; strips whitespace from begining and end of rendered TEMPLATE value(s).",
"{format}": "Use in form, '{format:TYPE:FORMAT,TEMPLATE}'; converts TEMPLATE value to TYPE then formats the value "
+ "using Python string formatting codes specified by FORMAT; TYPE is one of: 'int', 'float', or 'str'. "
"For example, '{format:float:.1f,{exiftool:EXIF:FocalLength}}' will format focal length to 1 decimal place (e.g. '100.0'). ",
"{function}": "Execute a python function from an external file and use return value as template substitution. " "{function}": "Execute a python function from an external file and use return value as template substitution. "
+ "Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. " + "Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. "
+ "The function will be passed the PhotoInfo object for the photo. " + "The function will be passed the PhotoInfo object for the photo. "
@ -239,6 +245,18 @@ FILTER_VALUES = {
"brackets": "Enclose value in brackets, e.g. 'value' => '[value]'", "brackets": "Enclose value in brackets, e.g. 'value' => '[value]'",
"shell_quote": "Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.", "shell_quote": "Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.",
"function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py", "function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py",
"split(x)": "Split value into a list of values using x as delimiter, e.g. 'value1;value2' => ['value1', 'value2'] if used with split(;).",
"autosplit": "Automatically split delimited string into separate values; will split strings delimited by comma, semicolon, or space, e.g. 'value1,value2' => ['value1', 'value2'].",
"chop(x)": "Remove x characters off the end of value, e.g. chop(1): 'Value' => 'Valu'; when applied to a list, chops characters from each list value, e.g. chop(1): ['travel', 'beach']=> ['trave', 'beac'].",
"chomp(x)": "Remove x characters from the beginning of value, e.g. chomp(1): ['Value'] => ['alue']; when applied to a list, removes characters from each list value, e.g. chomp(1): ['travel', 'beach']=> ['ravel', 'each'].",
"sort": "Sort list of values, e.g. ['c', 'b', 'a'] => ['a', 'b', 'c'].",
"rsort": "Sort list of values in reverse order, e.g. ['a', 'b', 'c'] => ['c', 'b', 'a'].",
"reverse": "Reverse order of values, e.g. ['a', 'b', 'c'] => ['c', 'b', 'a'].",
"uniq": "Remove duplicate values, e.g. ['a', 'b', 'c', 'b', 'a'] => ['a', 'b', 'c'].",
"join(x)": "Join list of values with delimiter x, e.g. join(:): ['a', 'b', 'c'] => 'a:b:c'; the DELIM option functions similar to join(x) but with DELIM, the join happens before being passed to any filters.",
"append(x)": "Append x to list of values, e.g. append(d): ['a', 'b', 'c'] => ['a', 'b', 'c', 'd'].",
"prepend(x)": "Prepend x to list of values, e.g. prepend(d): ['a', 'b', 'c'] => ['d', 'a', 'b', 'c'].",
"remove(x)": "Remove x from list of values, e.g. remove(b): ['a', 'b', 'c'] => ['a', 'c'].",
} }
# Just the substitutions without the braces # Just the substitutions without the braces
@ -382,6 +400,7 @@ class PhotoTemplate:
self.filepath = options.filepath self.filepath = options.filepath
self.quote = options.quote self.quote = options.quote
self.dest_path = options.dest_path self.dest_path = options.dest_path
self.variables = {}
def render( def render(
self, self,
@ -430,14 +449,13 @@ class PhotoTemplate:
def _render_statement( def _render_statement(
self, self,
statement, statement,
path_sep=None, field_arg=None,
): ):
path_sep = path_sep or self.path_sep
results = [] results = []
unmatched = [] unmatched = []
for ts in statement.template_strings: for ts in statement.template_strings:
results, unmatched = self._render_template_string( results, unmatched = self._render_template_string(
ts, results=results, unmatched=unmatched, path_sep=path_sep ts, results=results, unmatched=unmatched, field_arg=field_arg
) )
rendered_strings = results rendered_strings = results
@ -457,7 +475,7 @@ class PhotoTemplate:
def _render_template_string( def _render_template_string(
self, self,
ts, ts,
path_sep, field_arg,
results=None, results=None,
unmatched=None, unmatched=None,
): ):
@ -469,11 +487,6 @@ class PhotoTemplate:
if ts.template: if ts.template:
# have a template field to process # have a template field to process
field = ts.template.field field = ts.template.field
field_part = field.split(".")[0]
if field not in FIELD_NAMES and field_part not in FIELD_NAMES:
unmatched.append(field)
return [], unmatched
subfield = ts.template.subfield subfield = ts.template.subfield
# process filters # process filters
@ -481,14 +494,15 @@ class PhotoTemplate:
if ts.template.filter is not None: if ts.template.filter is not None:
filters = ts.template.filter.value filters = ts.template.filter.value
# process path_sep # process field arguments
if ts.template.pathsep is not None: if ts.template.fieldarg is not None:
path_sep = ts.template.pathsep.value field_arg = ts.template.fieldarg.value
# process delim # process delim
if ts.template.delim is not None: if ts.template.delim is not None:
# if value is None, means format was {+field} # if value is None, means format was {+field}
delim = ts.template.delim.value or "" delim = ts.template.delim.value or ""
delim = self.expand_variables_to_str(delim, "delim")
else: else:
delim = None delim = None
@ -497,7 +511,7 @@ class PhotoTemplate:
if ts.template.bool.value is not None: if ts.template.bool.value is not None:
bool_val, u = self._render_statement( bool_val, u = self._render_statement(
ts.template.bool.value, ts.template.bool.value,
path_sep=path_sep, field_arg=field_arg,
) )
unmatched.extend(u) unmatched.extend(u)
else: else:
@ -513,7 +527,7 @@ class PhotoTemplate:
if ts.template.default.value is not None: if ts.template.default.value is not None:
default, u = self._render_statement( default, u = self._render_statement(
ts.template.default.value, ts.template.default.value,
path_sep=path_sep, field_arg=field_arg,
) )
unmatched.extend(u) unmatched.extend(u)
else: else:
@ -528,11 +542,11 @@ class PhotoTemplate:
negation = ts.template.conditional.negation negation = ts.template.conditional.negation
if ts.template.conditional.value is not None: if ts.template.conditional.value is not None:
# conditional value is also a TemplateString # conditional value is also a TemplateString
conditional_value, u = self._render_statement( conditional_value = []
ts.template.conditional.value, for cv in ts.template.conditional.value:
path_sep=path_sep, value, u = self._render_statement(cv)
) conditional_value += value
unmatched.extend(u) unmatched.extend(u)
else: else:
# this shouldn't happen # this shouldn't happen
conditional_value = [""] conditional_value = [""]
@ -541,43 +555,23 @@ class PhotoTemplate:
negation = None negation = None
conditional_value = [] conditional_value = []
vals = [] if field.startswith("%"):
if ( # variable in form {%var}
field in SINGLE_VALUE_SUBSTITUTIONS vals = self.variables.get(field[1:], None)
or field.split(".")[0] in SINGLE_VALUE_SUBSTITUTIONS if vals is None:
): raise SyntaxError(f"Variable '{field[1:]}' is not defined.")
vals = self.get_template_value( elif field == "var":
field, if not subfield or not default:
default=default, raise SyntaxError(
subfield=subfield, "var must have a subfield and value in form {var:subfield,value}"
# delim=delim or self.inplace_sep,
# path_sep=path_sep,
)
elif field == "exiftool":
if subfield is None:
raise ValueError(
"SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'"
) )
vals = self.get_template_value_exiftool( self.variables[subfield] = default
subfield, vals = []
)
elif field == "function":
if subfield is None:
raise ValueError(
"SyntaxError: filename and function must not be null with {function::filename.py:function_name}"
)
vals = self.get_template_value_function(
subfield,
)
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
vals = self.get_template_value_multi(
field, subfield, path_sep=path_sep, default=default
)
elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS:
vals = self.get_template_value_pathlib(field)
else: else:
unmatched.append(field) vals, u = self.get_field_values(field, subfield, field_arg, default)
return [], unmatched if u:
unmatched.extend(u)
return [], unmatched
vals = [val for val in vals if val is not None] vals = [val for val in vals if val is not None]
@ -586,7 +580,7 @@ class PhotoTemplate:
vals = [sep.join(sorted(vals))] if vals else [] vals = [sep.join(sorted(vals))] if vals else []
for filter_ in filters: for filter_ in filters:
vals = self.get_template_value_filter(filter_, vals) vals = self.get_filter_values(filter_, vals)
# process find/replace # process find/replace
if ts.template.findreplace: if ts.template.findreplace:
@ -594,7 +588,9 @@ class PhotoTemplate:
for val in vals: for val in vals:
for pair in ts.template.findreplace.pairs: for pair in ts.template.findreplace.pairs:
find = pair.find or "" find = pair.find or ""
find = self.expand_variables_to_str(find, "find/replace")
repl = pair.replace or "" repl = pair.replace or ""
repl = self.expand_variables_to_str(repl, "find/replace")
val = val.replace(find, repl) val = val.replace(find, repl)
new_vals.append(val) new_vals.append(val)
vals = new_vals vals = new_vals
@ -621,22 +617,23 @@ class PhotoTemplate:
def comparison_test(test_function): def comparison_test(test_function):
"""Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation""" """Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation"""
if len(vals) != 1 or len(conditional_value) != 1: # returns True if any of the values match the condition
raise ValueError( if len(conditional_value) != 1:
f"comparison operators may only be used with a single value: {vals} {conditional_value}" raise SyntaxError(
f"comparison operators may only be used with a single conditional value: {conditional_value}"
) )
try: try:
match = bool( match = any(
test_function(float(vals[0]), float(conditional_value[0])) bool(test_function(float(v), float(conditional_value[0])))
for v in vals
) )
return ( return (
["True"] ["True"]
if (match and not negation) or (negation and not match) if (match and not negation) or (negation and not match)
else [] else []
) )
except ValueError as e: except ValueError as e:
raise ValueError( raise SyntaxError(
f"comparison operators may only be used with values that can be converted to numbers: {vals} {conditional_value}" f"comparison operators may only be used with values that can be converted to numbers: {vals} {conditional_value}"
) from e ) from e
@ -678,7 +675,8 @@ class PhotoTemplate:
if is_bool: if is_bool:
vals = default if not vals else bool_val vals = default if not vals else bool_val
elif not vals: elif not vals and field != "var":
# don't assign default value if the template was variable assignment
vals = default or [self.none_str] vals = default or [self.none_str]
pre = ts.pre or "" pre = ts.pre or ""
@ -700,14 +698,103 @@ class PhotoTemplate:
return results, unmatched return results, unmatched
def expand_variables_to_str(self, value: str, name: str) -> str:
"""
Expand variables in value and return a str of the expanded value.
Enforce that the expanded value is a single value, raises ValueError if not.
Args:
value: the value to expand
name: the name of the value being expanded (used in error messages)
"""
expanded = self.expand_variables(value)
if len(expanded) != 1:
raise SyntaxError(f"{name} must have a single value, not {expanded}")
return expanded[0]
def expand_variables(self, value: str) -> List[str]:
"""Expand variables in value"""
# replace any variables with their values
values = [value]
new_values = []
# allow %% to escape %, match variables in form %var
variable_match = re.compile(r"(?:%%)*(%[\w]+)?")
while True:
for value in values:
match = variable_match.search(value)
if not match or not match[1]:
break
var = match[1]
var_name = var[1:]
if var_name not in self.variables:
raise SyntaxError(f"Variable '{var_name}' is not defined.")
for val in values:
for var_val in self.variables[var_name]:
new_values.append(
re.sub(f"(%%)*{var}", r"\g<1>" + var_val, val)
)
if new_values == values or not new_values:
break
values = new_values.copy()
new_values = []
# replace %% with %
# any %% left in the string will be replaced with %
values = [value.replace("%%", "%") for value in values]
return values
def get_field_values(
self,
field: str,
subfield: Optional[str],
field_arg: Optional[str],
default: List[str],
) -> Tuple[List[str], List[str]]:
"""Get the values for a field"""
vals = []
unmatched = []
if (
field in SINGLE_VALUE_SUBSTITUTIONS
or field.split(".")[0] in SINGLE_VALUE_SUBSTITUTIONS
):
vals = self.get_template_value(
field,
default=default,
subfield=subfield,
field_arg=field_arg,
)
elif field == "exiftool":
if subfield is None:
raise ValueError(
"SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'"
)
vals = self.get_template_value_exiftool(
subfield,
)
elif field == "function":
if subfield is None:
raise ValueError(
"SyntaxError: filename and function must not be null with {function::filename.py:function_name}"
)
vals = self.get_template_value_function(subfield, field_arg)
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
vals = self.get_template_value_multi(
field, subfield, path_sep=field_arg, default=default
)
elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS:
vals = self.get_template_value_pathlib(field)
else:
unmatched.append(field)
return [], unmatched
return vals, unmatched
def get_template_value( def get_template_value(
self, self,
field, field,
default, default,
subfield=None, subfield,
# bool_val=None, field_arg,
# delim=None,
# path_sep=None,
): ):
"""lookup value for template field (single-value template substitutions) """lookup value for template field (single-value template substitutions)
@ -1004,9 +1091,8 @@ class PhotoTemplate:
else: else:
value = None value = None
if value is not None: if value is not None:
with suppress(IndexError): start_id = int(field_arg) if field_arg is not None else 0
start_id = field.split(".", 1) value = int(value) + start_id
value = int(value) + int(start_id[1])
value = format_str_value(value, subfield) value = format_str_value(value, subfield)
else: else:
# if here, didn't get a match # if here, didn't get a match
@ -1054,54 +1140,113 @@ class PhotoTemplate:
return [value] return [value]
def get_template_value_filter(self, filter_, values): def get_filter_values(self, filter_: str, values: List[str]) -> List[str]:
"""Return filtered values"""
# extract args, if any
if re.search(r"\(.*\)", filter_):
filter_, args = filter_.split("(", 1)
args = args.rstrip(")")
args = self.expand_variables_to_str(args, "Filter arguments")
else:
args = None
# check that filter name (without subfields or arguments) is valid
valid_filters = [f.split("(")[0] for f in FILTER_VALUES]
if filter_.split(":")[0] not in valid_filters:
raise SyntaxError(f"Unknown filter: {filter_}")
if filter_ in [
"split",
"chop",
"chomp",
"join",
"append",
"prepend",
"remove",
] and (args is None or not len(args)):
raise SyntaxError(f"{filter_} requires arguments")
if filter_ == "lower": if filter_ == "lower":
if values and type(values) == list: value = [v.lower() for v in values]
value = [v.lower() for v in values]
else:
value = [values.lower()] if values else []
elif filter_ == "upper": elif filter_ == "upper":
if values and type(values) == list: value = [v.upper() for v in values]
value = [v.upper() for v in values]
else:
value = [values.upper()] if values else []
elif filter_ == "strip": elif filter_ == "strip":
if values and type(values) == list: value = [v.strip() for v in values]
value = [v.strip() for v in values]
else:
value = [values.strip()] if values else []
elif filter_ == "capitalize": elif filter_ == "capitalize":
if values and type(values) == list: value = [v.capitalize() for v in values]
value = [v.capitalize() for v in values]
else:
value = [values.capitalize()] if values else []
elif filter_ == "titlecase": elif filter_ == "titlecase":
if values and type(values) == list: value = [v.title() for v in values]
value = [v.title() for v in values]
else:
value = [values.title()] if values else []
elif filter_ == "braces": elif filter_ == "braces":
if values and type(values) == list: value = ["{" + v + "}" for v in values]
value = ["{" + v + "}" for v in values]
else:
value = ["{" + values + "}"] if values else []
elif filter_ == "parens": elif filter_ == "parens":
if values and type(values) == list: value = ["(" + v + ")" for v in values]
value = [f"({v})" for v in values]
else:
value = [f"({values})"] if values else []
elif filter_ == "brackets": elif filter_ == "brackets":
if values and type(values) == list: value = ["[" + v + "]" for v in values]
value = [f"[{v}]" for v in values]
else:
value = [f"[{values}]"] if values else []
elif filter_ == "shell_quote": elif filter_ == "shell_quote":
if values and type(values) == list: value = [shlex.quote(v) for v in values]
value = [shlex.quote(v) for v in values] elif filter_ == "split":
# split on delimiter
delim = args
if delim:
new_values = []
for v in values:
new_values.extend(v.split(delim))
value = new_values
else: else:
value = [shlex.quote(values)] if values else [] value = values
elif filter_ == "chop":
# chop off characters from the end
try:
chop = int(args)
except ValueError:
raise SyntaxError(f"Invalid value for chop: {args}")
value = [v[:-chop] for v in values] if chop else values
elif filter_ == "chomp":
# chop off characters from the beginning
try:
chomp = int(args)
except ValueError:
raise SyntaxError(f"Invalid value for chomp: {args}")
value = [v[chomp:] for v in values] if chomp else values
elif filter_ == "autosplit":
# try to split keyword strings automatically
temp_values = [v.replace(",", " ") for v in values]
temp_values = [v.replace(";", " ") for v in temp_values]
value = []
for val in temp_values:
value.extend(val.split())
elif filter_ == "sort":
# sort list of values
value = sorted(values)
elif filter_ == "rsort":
# reverse sort list of values
value = sorted(values, reverse=True)
elif filter_ == "reverse":
# reverse list of values
value = values[::-1]
elif filter_ == "uniq":
# remove duplicate values from list
temp_values = []
for v in values:
if v not in temp_values:
temp_values.append(v)
value = temp_values
elif filter_ == "join":
# join list of values with delimiter
delim = args
value = [delim.join(values)]
elif filter_ == "append":
# append value to list
value = values + [args]
elif filter_ == "prepend":
# prepend value to list
value = [args] + values
elif filter_ == "remove":
# remove value from list
value = [v for v in values if v != args]
elif filter_.startswith("function:"): elif filter_.startswith("function:"):
value = self.get_template_value_filter_function(filter_, values) value = self.get_template_value_filter_function(filter_, args, values)
else: else:
value = [] value = []
return value return value
@ -1124,6 +1269,8 @@ class PhotoTemplate:
""" return list of values for a multi-valued template field """ """ return list of values for a multi-valued template field """
path_sep = path_sep or self.path_sep
if self.photo.uuid is None: if self.photo.uuid is None:
return [] return []
@ -1189,6 +1336,8 @@ class PhotoTemplate:
values = [shlex.quote(v) for v in default if v] values = [shlex.quote(v) for v in default if v]
elif field == "strip": elif field == "strip":
values = [v.strip() for v in default] values = [v.strip() for v in default]
elif field == "format":
values = self.get_format_values(field, subfield, default)
elif field.startswith("photo"): elif field.startswith("photo"):
# provide access to PhotoInfo object # provide access to PhotoInfo object
properties = field.split(".") properties = field.split(".")
@ -1203,10 +1352,11 @@ class PhotoTemplate:
obj = getattr(obj, property_) obj = getattr(obj, property_)
if obj is None: if obj is None:
break break
except AttributeError: except AttributeError as e:
raise ValueError( raise ValueError(
"Invalid property for {photo} template: " + f"'{property_}'" "Invalid property for {photo} template: " + f"'{property_}'"
) ) from e
if obj is None: if obj is None:
values = [] values = []
elif isinstance(obj, bool): elif isinstance(obj, bool):
@ -1231,6 +1381,31 @@ class PhotoTemplate:
values = values or [] values = values or []
return values return values
def get_format_values(
self, field: str, subfield: str, default: List[str]
) -> Optional[List[Optional[str]]]:
"""Return values for {format} templates"""
if field != "format":
raise ValueError(f"Unhandled template value in get_format_values: {field}")
if not subfield or ":" not in subfield:
raise SyntaxError("{format} requires subfield in form TYPE:FORMAT")
type_, format_str = subfield.split(":", 1)
if type_ not in ("int", "float", "str"):
raise SyntaxError(
f"'{type_}' is not a valid type for {format}: must be one of 'int', 'float', 'str'"
)
if type_ == "int":
# convert to float then int to avoid error when converting a string float to int
default_ = [int(float(v)) for v in default]
elif type_ == "float":
default_ = [float(v) for v in default]
else:
default_ = default
format_str = self.expand_variables_to_str(format_str, "format string")
return [format_str_value(v, format_str) for v in default_]
def get_template_value_exiftool( def get_template_value_exiftool(
self, self,
subfield, subfield,
@ -1264,6 +1439,7 @@ class PhotoTemplate:
def get_template_value_function( def get_template_value_function(
self, self,
subfield, subfield,
field_arg,
): ):
"""Get template value from external function""" """Get template value from external function"""
@ -1279,7 +1455,13 @@ class PhotoTemplate:
raise ValueError(f"'{filename}' does not appear to be a file") raise ValueError(f"'{filename}' does not appear to be a file")
template_func = load_function(filename_validated, funcname) template_func = load_function(filename_validated, funcname)
values = template_func(self.photo, options=self.options) if self.photo.uuid is None:
# must be a PhotoInfoNone instance
# if no uuid, then template is being validated but not actually run
# so don't run the function
values = []
else:
values = template_func(self.photo, options=self.options, args=field_arg)
if not isinstance(values, (str, list)): if not isinstance(values, (str, list)):
raise TypeError( raise TypeError(
@ -1297,8 +1479,9 @@ class PhotoTemplate:
return values return values
def get_template_value_filter_function(self, filter_, values): def get_template_value_filter_function(self, filter_, args, values):
"""Filter template value from external function""" """Filter template value from external function"""
# TODO: add args to filter function call? Would change signature of function
filter_ = filter_.replace("function:", "") filter_ = filter_.replace("function:", "")
@ -1317,7 +1500,11 @@ class PhotoTemplate:
if not isinstance(values, (list, tuple)): if not isinstance(values, (list, tuple)):
values = [values] values = [values]
values = template_func(values)
if self.photo.uuid is not None:
# if uuid is None, it's a PhotoInfoNone instance and template is being validated
# so don't run the function
values = template_func(values)
if not isinstance(values, list): if not isinstance(values, list):
raise TypeError( raise TypeError(
@ -1329,10 +1516,7 @@ class PhotoTemplate:
def get_photo_video_type(self, default): def get_photo_video_type(self, default):
"""return media type, e.g. photo or video""" """return media type, e.g. photo or video"""
default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS) default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS)
if self.photo.isphoto: return default_dict["photo"] if self.photo.isphoto else default_dict["video"]
return default_dict["photo"]
else:
return default_dict["video"]
def get_media_type(self, default): def get_media_type(self, default):
"""return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type""" """return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type"""
@ -1360,12 +1544,8 @@ class PhotoTemplate:
return default_dict["photo"] return default_dict["photo"]
def get_photo_bool_attribute(self, attr, default, bool_val): def get_photo_bool_attribute(self, attr, default, bool_val):
# get value for a PhotoInfo bool attribute """Return the boolean value for a photo attribute"""
val = getattr(self.photo, attr) return bool_val if (val := getattr(self.photo, attr)) else default
if val:
return bool_val
else:
return default
def parse_default_kv(default, default_dict): def parse_default_kv(default, default_dict):

View File

@ -1,6 +1,6 @@
// OSXPhotos Metadata Template Language (MTL) // OSXPhotos Metadata Template Language (MTL)
// a TemplateString has format: // a TemplateString has format:
// pre{delim+template_field:subfield|filter(path_sep)[find,replace] conditional?bool_value,default}post // pre{delim+template_field:subfield(field_arg)|filter[find,replace] conditional?bool_value,default}post
// a TemplateStatement may contain zero or more TemplateStrings // a TemplateStatement may contain zero or more TemplateStrings
// The pre and post are optional strings // The pre and post are optional strings
// The template itself (inside the {}) is also optional but if present // The template itself (inside the {}) is also optional but if present
@ -22,8 +22,8 @@ Template:
delim=Delim delim=Delim
field=Field field=Field
subfield=SubField subfield=SubField
fieldarg=FieldArg
filter=Filter filter=Filter
pathsep=PathSep
findreplace=FindReplace findreplace=FindReplace
conditional=Conditional conditional=Conditional
bool=Boolean bool=Boolean
@ -52,7 +52,7 @@ Field:
; ;
FIELD_WORD: FIELD_WORD:
/[\.\w]+/ /[\%]?[\.\w]+/
; ;
SubField: SubField:
@ -70,21 +70,22 @@ SUBFIELD_WORD:
Filter: Filter:
( (
"|"- "|"-
(value+=FILTER_WORD['|'])? (value+=FILTER_FUNCTION['|'])?
)? )?
; ;
FILTER_WORD: FILTER_FUNCTION:
/[\.\w:\/]+/ /[\.\w:\/]+(\([^\)]*\))?/
; ;
Conditional: Conditional:
( (
(" "+)- (" "+)-
(negation=NEGATION)? (negation=NEGATION)?
(operator=OPERATOR) (operator=OPERATOR)
(" "+)- (" "+)-
(value=Statement) (value+=Statement['|'])
)? )?
; ;
@ -96,7 +97,7 @@ OPERATOR:
"contains" | "matches" | "startswith" | "endswith" | "<=" | ">=" | "<" | ">" | "==" | "!=" "contains" | "matches" | "startswith" | "endswith" | "<=" | ">=" | "<" | ">" | "==" | "!="
; ;
PathSep: FieldArg:
( (
"(" "("
(value=/[^\(\)\{\}]+/)? (value=/[^\(\)\{\}]+/)?

View File

@ -198,6 +198,34 @@ TEMPLATE_VALUES = {
"{title?Title is '{title} - {descr}',No Title}": "Title is 'Glen Ord - Jack Rose Dining Saloon'", "{title?Title is '{title} - {descr}',No Title}": "Title is 'Glen Ord - Jack Rose Dining Saloon'",
"{favorite}": "_", "{favorite}": "_",
"{favorite?FAV,NOTFAV}": "NOTFAV", "{favorite?FAV,NOTFAV}": "NOTFAV",
"{var:myvar,{semicolon}}{created.dow}{%myvar}": "Tuesday;",
"{var:pipe,{pipe}}{place.address[,,%pipe]}": "2038 18th St NW| Washington| DC 20009| United States",
"{format:float:.2f,{photo.exif_info.aperture}}": "2.20",
"{format:int:02d,{photo.exif_info.aperture}}": "02",
"{format:int:03d,{photo.exif_info.aperture}}": "002",
"{format:float:10.4f,{photo.exif_info.aperture}}": " 2.2000",
"{format:str:-^10,{photo.exif_info.aperture}}": "---2.2----",
"{descr|lower}": "jack rose dining saloon",
"{descr|upper}": "JACK ROSE DINING SALOON",
"{var:spaces, {descr}}{%spaces|strip,}": "Jack Rose Dining Saloon",
"{descr|titlecase}": "Jack Rose Dining Saloon",
"{descr|capitalize}": "Jack rose dining saloon",
"{descr|braces}": "{Jack Rose Dining Saloon}",
"{descr|parens}": "(Jack Rose Dining Saloon)",
"{descr|brackets}": "[Jack Rose Dining Saloon]",
"{descr|split( )|join(|)}": "Jack|Rose|Dining|Saloon",
"{descr|autosplit|join(|)}": "Jack|Rose|Dining|Saloon",
"{descr|autosplit|chop(1)|join(|)}": "Jac|Ros|Dinin|Saloo",
"{descr|autosplit|chomp(1)|join(|)}": "ack|ose|ining|aloon",
"{descr|chop(2)}": "Jack Rose Dining Salo",
"{descr|chomp(2)}": "ck Rose Dining Saloon",
"{descr|autosplit|sort|join(|)}": "Dining|Jack|Rose|Saloon",
"{descr|autosplit|rsort|join(|)}": "Saloon|Rose|Jack|Dining",
"{descr|autosplit|reverse|join(|)}": "Saloon|Dining|Rose|Jack",
"{var:myvar,a e a b d c c d e}{%myvar|autosplit|uniq|sort|join(,)}": "a,b,c,d,e",
"{descr|chop(6)|autosplit|append(Restaurant)|join( )}": "Jack Rose Dining Restaurant",
"{descr|chomp(4)|autosplit|prepend(Mack)|join( )}": "Mack Rose Dining Saloon",
"{descr|autosplit|remove(Rose)|join( )}": "Jack Dining Saloon",
} }
@ -323,9 +351,11 @@ UUID_CONDITIONAL = {
"{title endswith Park?YES,NO}": ["YES"], "{title endswith Park?YES,NO}": ["YES"],
"{title endswith Elder?YES,NO}": ["NO"], "{title endswith Elder?YES,NO}": ["NO"],
"{title startswith Elder?YES,NO}": ["YES"], "{title startswith Elder?YES,NO}": ["YES"],
"{title startswith Elder|endswith Park?YES,NO}": ["YES"],
"{title endswith Elder?YES,NO}": ["NO"], "{title endswith Elder?YES,NO}": ["NO"],
"{photo.place.name contains Adelaide?YES,NO}": ["YES"], "{photo.place.name contains Adelaide?YES,NO}": ["YES"],
"{photo.place.name|lower contains adelaide?YES,NO}": ["YES"], "{photo.place.name|lower contains adelaide?YES,NO}": ["YES"],
"{photo.place.name|lower contains adelaide|australia?YES,NO}": ["YES"],
"{photo.place.name|lower not contains adelaide?YES,NO}": ["NO"], "{photo.place.name|lower not contains adelaide?YES,NO}": ["NO"],
"{photo.score.overall < 0.7?YES,NO}": ["YES"], "{photo.score.overall < 0.7?YES,NO}": ["YES"],
"{photo.score.overall <= 0.7?YES,NO}": ["YES"], "{photo.score.overall <= 0.7?YES,NO}": ["YES"],
@ -360,12 +390,14 @@ UUID_ALBUM_SEQ = {
"templates": { "templates": {
"{album_seq}": "0", "{album_seq}": "0",
"{album_seq:02d}": "00", "{album_seq:02d}": "00",
"{album_seq.1}": "1", "{album_seq(1)}": "1",
"{album_seq.1:03d}": "001", "{album_seq(2)}": "2",
"{album_seq:03d(1)}": "001",
"{folder_album_seq}": "0", "{folder_album_seq}": "0",
"{folder_album_seq:02d}": "00", "{folder_album_seq:02d}": "00",
"{folder_album_seq.1}": "1", "{folder_album_seq(1)}": "1",
"{folder_album_seq.1:03d}": "001", "{folder_album_seq(2)}": "2",
"{folder_album_seq:03d(1)}": "001",
}, },
}, },
"F12384F6-CD17-4151-ACBA-AE0E3688539E": { "F12384F6-CD17-4151-ACBA-AE0E3688539E": {
@ -373,12 +405,12 @@ UUID_ALBUM_SEQ = {
"templates": { "templates": {
"{album_seq}": "2", "{album_seq}": "2",
"{album_seq:02d}": "02", "{album_seq:02d}": "02",
"{album_seq.1}": "3", "{album_seq(1)}": "3",
"{album_seq.1:03d}": "003", "{album_seq:03d(1)}": "003",
"{folder_album_seq}": "2", "{folder_album_seq}": "2",
"{folder_album_seq:02d}": "02", "{folder_album_seq:02d}": "02",
"{folder_album_seq.1}": "3", "{folder_album_seq(1)}": "3",
"{folder_album_seq.1:03d}": "003", "{folder_album_seq:03d(1)}": "003",
}, },
}, },
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": { "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
@ -386,13 +418,13 @@ UUID_ALBUM_SEQ = {
"templates": { "templates": {
"{album_seq}": "1", "{album_seq}": "1",
"{album_seq:02d}": "01", "{album_seq:02d}": "01",
"{album_seq.1}": "2", "{album_seq(1)}": "2",
"{album_seq.1:03d}": "002", "{album_seq:03d(1)}": "002",
"{folder_album_seq}": "1", "{folder_album_seq}": "1",
"{folder_album_seq:02d}": "01", "{folder_album_seq:02d}": "01",
"{folder_album_seq.1}": "2", "{folder_album_seq(1)}": "2",
"{folder_album_seq.0}": "1", "{folder_album_seq(0)}": "1",
"{folder_album_seq.1:03d}": "002", "{folder_album_seq:03d(1)}": "002",
}, },
}, },
} }
@ -469,7 +501,7 @@ def test_lookup(photosdb_places):
for subst in TEMPLATE_SUBSTITUTIONS: for subst in TEMPLATE_SUBSTITUTIONS:
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1) lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
lookup = template.get_template_value(lookup_str, None) lookup = template.get_template_value(lookup_str, None, None, None)
assert lookup or lookup is None assert lookup or lookup is None
@ -480,7 +512,7 @@ def test_lookup_multi(photosdb_places):
for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED: for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED:
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1) lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
if subst in ["{exiftool}", "{photo}", "{function}"]: if subst in ["{exiftool}", "{photo}", "{function}", "{format}"]:
continue continue
lookup = template.get_template_value_multi( lookup = template.get_template_value_multi(
lookup_str, lookup_str,
@ -783,7 +815,7 @@ def test_subst_multi_folder_albums_1_path_sep_lower(photosdb):
# photo in an album in a folder # photo in an album in a folder
photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0] photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0]
template = "{folder_album|lower(:)}" template = "{folder_album(:)|lower}"
expected = [ expected = [
"2018-10 - sponsion, museum, frühstück, römermuseum", "2018-10 - sponsion, museum, frühstück, römermuseum",
"2019-10/11 paris clermont", "2019-10/11 paris clermont",
@ -851,7 +883,7 @@ def test_subst_multi_folder_albums_4_path_sep_lower(photosdb_14_6):
# photo in an album in a folder # photo in an album in a folder
photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0] photo = photosdb_14_6.photos(uuid=[UUID_DICT["mojave_album_1"]])[0]
template = "{folder_album|lower(>)}" template = "{folder_album(>)|lower}"
expected = ["folder1>subfolder2>albuminfolder", "pumpkin farm", "test album (1)"] expected = ["folder1>subfolder2>albuminfolder", "pumpkin farm", "test album (1)"]
rendered, unknown = photo.render_template(template) rendered, unknown = photo.render_template(template)
assert sorted(rendered) == sorted(expected) assert sorted(rendered) == sorted(expected)
@ -1134,7 +1166,9 @@ def test_function_filter(photosdb):
def test_function_filter_bad(photosdb): def test_function_filter_bad(photosdb):
"""Test invalid {field|function} filter""" """Test invalid {field|function} filter"""
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS) photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
with pytest.raises(ValueError): # bad field raises SyntaxError
# bad function raises ValueError
with pytest.raises((SyntaxError, ValueError)):
rendered, _ = photo.render_template( rendered, _ = photo.render_template(
"{photo.original_filename|function:tests/template_filter.py::foobar}" "{photo.original_filename|function:tests/template_filter.py::foobar}"
) )