Implements conditional expressions for template system, #417

This commit is contained in:
Rhet Turnbull
2021-04-13 06:20:56 -07:00
parent e215c200c7
commit 03f8b2bc6e
16 changed files with 340 additions and 44 deletions

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.41.11"
__version__ = "0.42.00"

View File

@@ -4,7 +4,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:
`"pretext{delim+template_field:subfield|filter(path_sep)[find,replace]?bool_value,default}posttext"`
`"pretext{delim+template_field:subfield|filter(path_sep)[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.
@@ -62,7 +62,41 @@ e.g. If Photo is in `Album1` in `Folder1`:
`[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.
`conditional`: optional conditional expression that is evaluated as boolean (True/False) for use with the `?bool_value` modifier. Conditional expressions take the form '` not operator value`' where `not` is an optional modifier that negates the `operator`. Note: the space before the conditional expression is required if you use a conditional expression. Valid comparison operators are:
- `contains`: template field contains value, similar to python's `in`
- `matches`: template field contains exactly value, unlike `contains`: does not match partial matches
- `startswith`: template field starts with value
- `endswith`: template field ends with value
- `<=`: template field is less than or equal to value
- `>=`: template field is greater than or equal to value
- `<`: template field is less than value
- `>`: template field is greater than value
- `==`: template field equals value
- `!=`: template field does not equal value
The `value` part of the conditional expression is treated as a bare (unquoted) word/phrase. Multiple values may be separated by '|' (the pipe symbol). `value` is itself a template statement so you can use one or more template fields in `value` which will be resolved before the comparison occurs.
For example:
- `{keyword matches Beach}` resolves to True if 'Beach' is a keyword. It would not match keyword 'BeachDay'.
- `{keyword contains Beach}` resolves to True if any keyword contains the word 'Beach' so it would match both 'Beach' and 'BeachDay'.
- `{photo.score.overall > 0.7}` resolves to True if the photo's overall aesthetic score is greater than 0.7.
- `{keyword|lower contains beach}` uses the lower case filter to do case-insensitive matching to match any keyword that contains the word 'beach'.
- `{keyword|lower not contains beach}` uses the `not` modifier to negate the comparison so this resolves to True if there is no keyword that matches 'beach'.
Examples: to export photos that contain certain keywords with the `osxphotos export` command's `--directory` option:
`--directory "{keyword|lower matches travel|vacation?Travel-Photos,Not-Travel-Photos}"`
This exports any photo that has keywords 'travel' or 'vacation' into a directory 'Travel-Photos' and all other photos into directory 'Not-Travel-Photos'.
This can be used to rename files as well, for example:
`--filename "{favorite?Favorite-{original_name},{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.
e.g. if photo is an HDR image,

View File

@@ -120,6 +120,7 @@ TEMPLATE_SUBSTITUTIONS = {
"{uuid}": "Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'",
"{comma}": "A comma: ','",
"{semicolon}": "A semicolon: ';'",
"{questionmark}": "A question mark: '?'",
"{pipe}": "A vertical pipe: '|'",
"{openbrace}": "An open brace: '{'",
"{closebrace}": "A close brace: '}'",
@@ -191,6 +192,7 @@ PUNCTUATION = {
"closeparens": ")",
"openbracket": "[",
"closebracket": "]",
"questionmark": "?",
}
@@ -323,17 +325,6 @@ class PhotoTemplate:
unmatched=unmatched,
)
# 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:
@@ -430,6 +421,30 @@ class PhotoTemplate:
else:
default = []
# process conditional
if ts.template.conditional is not None:
operator = ts.template.conditional.operator
negation = ts.template.conditional.negation
if ts.template.conditional.value is not None:
# conditional value is also a TemplateString
conditional_value, u = self._render_statement(
ts.template.conditional.value,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
)
unmatched.extend(u)
else:
# this shouldn't happen
conditional_value = [""]
else:
operator = None
negation = None
conditional_value = []
vals = []
if field in SINGLE_VALUE_SUBSTITUTIONS:
vals = self.get_template_value(
@@ -458,14 +473,6 @@ class PhotoTemplate:
vals = [val for val in vals if val is not None]
if is_bool:
if not vals:
vals = default
else:
vals = bool_val
elif not vals:
vals = default or [none_str]
if expand_inplace or delim is not None:
sep = delim if delim is not None else inplace_sep
vals = [sep.join(sorted(vals))]
@@ -473,6 +480,99 @@ class PhotoTemplate:
for filter_ in filters:
vals = self.get_template_value_filter(filter_, vals)
# process find/replace
if ts.template.findreplace:
new_vals = []
for val in vals:
for pair in ts.template.findreplace.pairs:
find = pair.find or ""
repl = pair.replace or ""
val = val.replace(find, repl)
new_vals.append(val)
vals = new_vals
if operator:
# have a conditional operator
def string_test(test_function):
""" Perform string comparison using test_function; closure to capture conditional_value, vals, negation """
match = False
for c in conditional_value:
for v in vals:
if test_function(v, c):
match = True
break
if match:
break
if (match and not negation) or (negation and not match):
return ["True"]
else:
return []
def comparison_test(test_function):
""" Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation """
if len(vals) != 1 or len(conditional_value) != 1:
raise ValueError(
f"comparison operators may only be used with a single value: {vals} {conditional_value}"
)
try:
match = (
True
if test_function(
float(vals[0]), float(conditional_value[0])
)
else False
)
if (match and not negation) or (negation and not match):
return ["True"]
else:
return []
except ValueError as e:
raise ValueError(
f"comparison operators may only be used with values that can be converted to numbers: {vals} {conditional_value}"
)
if operator in ["contains", "matches", "startswith", "endswith"]:
# process any "or" values separated by "|"
temp_values = []
for c in conditional_value:
temp_values.extend(c.split("|"))
conditional_value = temp_values
if operator == "contains":
vals = string_test(lambda v, c: c in v)
elif operator == "matches":
vals = string_test(lambda v, c: v == c)
elif operator == "startswith":
vals = string_test(lambda v, c: v.startswith(c))
elif operator == "endswith":
vals = string_test(lambda v, c: v.endswith(c))
elif operator == "==":
match = sorted(vals) == sorted(conditional_value)
if (match and not negation) or (negation and not match):
vals = ["True"]
else:
vals = []
elif operator == "!=":
match = sorted(vals) != sorted(conditional_value)
if (match and not negation) or (negation and not match):
vals = ["True"]
else:
vals = []
elif operator == "<":
vals = comparison_test(lambda v, c: v < c)
elif operator == "<=":
vals = comparison_test(lambda v, c: v <= c)
elif operator == ">":
vals = comparison_test(lambda v, c: v > c)
elif operator == ">=":
vals = comparison_test(lambda v, c: v >= c)
if is_bool:
vals = default if not vals else bool_val
elif not vals:
vals = default or [none_str]
pre = ts.pre or ""
post = ts.post or ""

View File

@@ -1,6 +1,6 @@
// OSXPhotos Template Language (OTL)
// a TemplateString has format:
// pre{delim+template_field:subfield|filter(path_sep)[find,replace]?bool_value,default}post
// pre{delim+template_field:subfield|filter(path_sep)[find,replace] conditional?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
@@ -25,6 +25,7 @@ Template:
filter=Filter
pathsep=PathSep
findreplace=FindReplace
conditional=Conditional
bool=Boolean
default=Default
"}"
@@ -32,7 +33,7 @@ Template:
;
NON_TEMPLATE_STRING:
/[^\{\},]*/
/[^\{\},\?]*/
;
Delim:
@@ -76,6 +77,24 @@ FILTER_WORD:
/[\.\w]+/
;
Conditional:
(
(" "+)-
(negation=NEGATION)?
(operator=OPERATOR)
(" "+)-
(value=Statement)
)?
;
NEGATION:
"not "
;
OPERATOR:
"contains" | "matches" | "startswith" | "endswith" | "<=" | ">=" | "<" | ">" | "==" | "!="
;
PathSep:
(
"("