Added --post-command, implements #443
This commit is contained in:
129
README.md
129
README.md
@@ -482,6 +482,40 @@ Then the next to you run osxphotos, you can simply do this:
|
||||
|
||||
The configuration file is a plain text file in [TOML](https://toml.io/en/) format so the `.toml` extension is standard but you can name the file anything you like.
|
||||
|
||||
#### Run commands on exported photos for post-processing
|
||||
|
||||
You can use the `--post-command` option to run one or more commands against exported files. The `--post-command` option takes two arguments: CATEGORY and COMMAND. CATEGORY is a string that describes which category of file to run the command against. The available categories are described in the help text available via: `osxphotos help export`. For example, the `exported` category includes all exported photos and the `skipped` category includes all photos that were skipped when running export with `--update`. COMMAND is an osxphotos template string which will be rendered then passed to the shell for execution.
|
||||
|
||||
For example, the following command generates a log of all exported files and their associated keywords:
|
||||
|
||||
`osxphotos export /path/to/export --post-command exported "echo {shell_quote,{filepath}{comma}{,+keyword,}} >> {shell_quote,{export_dir}/exported.txt}"`
|
||||
|
||||
The special template field `{shell_quote}` ensures a string is properly quoted for execution in the shell. For example, it's possible that a file path or keyword in this example has a space in the value and if not properly quoted, this would cause an error in the execution of the command. When running commands, the template `{filepath}` is set to the full path of the exported file and `{export_dir}` is set to the full path of the base export directory.
|
||||
|
||||
Explanation of the template string:
|
||||
|
||||
```txt
|
||||
{shell_quote,{filepath}{comma}{,+keyword,}}
|
||||
│ │ │ │ │
|
||||
│ │ │ | │
|
||||
└──> quote everything after comma for proper execution in the shell
|
||||
│ │ │ │
|
||||
└───> filepath of the exported file
|
||||
│ │ │
|
||||
└───> insert a comma
|
||||
│ │
|
||||
└───> join the list of keywords together with a ","
|
||||
│
|
||||
└───> if no keywords, insert nothing (empty string: "")
|
||||
```
|
||||
|
||||
Another example: if you had `exiftool` installed and wanted to wipe all metadata from all exported files, you could use the following:
|
||||
|
||||
`osxphotos export /path/to/export --post-command exported "/usr/local/bin/exiftool -all= {filepath|shell_quote}"`
|
||||
|
||||
This command uses the `|shell_quote` template filter instead of the `{shell_quote}` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text.
|
||||
|
||||
|
||||
#### An example from an actual osxphotos user
|
||||
|
||||
Here's a comprehensive use case from an actual osxphotos user that integrates many of the concepts discussed in this tutorial (thank-you Philippe for contributing this!):
|
||||
@@ -1009,6 +1043,24 @@ Options:
|
||||
feature is currently experimental. I don't
|
||||
know how well it will work on large export
|
||||
sets.
|
||||
--post-command CATEGORY COMMAND
|
||||
Run COMMAND on exported files of category
|
||||
CATEGORY. CATEGORY can be one of: exported,
|
||||
new, updated, skipped, missing, exif_updated,
|
||||
touched, converted_to_jpeg,
|
||||
sidecar_json_written, sidecar_json_skipped,
|
||||
sidecar_exiftool_written,
|
||||
sidecar_exiftool_skipped, sidecar_xmp_written,
|
||||
sidecar_xmp_skipped, error. COMMAND is an
|
||||
osxphotos template string, for example: '--
|
||||
post-command exported "echo
|
||||
{filepath|shell_quote} >>
|
||||
{export_dir}/exported.txt"', which appends the
|
||||
full path of all exported files to the file
|
||||
'exported.txt'. You can run more than one
|
||||
command by repeating the '--post-command'
|
||||
option with different arguments. See Post
|
||||
Command below.
|
||||
--exportdb EXPORTDB_FILE Specify alternate name for database file which
|
||||
stores state information for export and
|
||||
--update. If --exportdb is not specified,
|
||||
@@ -1166,6 +1218,8 @@ Valid filters are:
|
||||
• 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
|
||||
@@ -1505,7 +1559,7 @@ Substitution Description
|
||||
{lf} A line feed: '\n', alias for {newline}
|
||||
{cr} A carriage return: '\r'
|
||||
{crlf} a carriage return + line feed: '\r\n'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.42.34'
|
||||
{osxphotos_version} The osxphotos version, e.g. '0.42.35'
|
||||
{osxphotos_cmd_line} The full command line used to run osxphotos
|
||||
|
||||
The following substitutions may result in multiple values. Thus if specified for
|
||||
@@ -1564,6 +1618,10 @@ Substitution Description
|
||||
underlying PhotoInfo class. See
|
||||
https://rhettbull.github.io/osxphotos/ for additional
|
||||
documentation on the PhotoInfo class.
|
||||
{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.
|
||||
{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
|
||||
@@ -1596,6 +1654,71 @@ Substitution Description
|
||||
{filepath} The full path to the exported file
|
||||
|
||||
|
||||
** Post Command **
|
||||
You can run commands on the exported photos for post-processing using the '--
|
||||
post-command' option. '--post-command' is passed a CATEGORY and a COMMAND.
|
||||
COMMAND is an osxphotos template string which will be rendered and passed to the
|
||||
shell for execution. CATEGORY is the category of file to pass to COMMAND. The
|
||||
following categories are available:
|
||||
|
||||
Catgory Description
|
||||
exported All exported files
|
||||
new When used with '--update', all newly exported files
|
||||
updated When used with '--update', all files which were
|
||||
previously exported but updated this time
|
||||
skipped When used with '--update', all files which were
|
||||
skipped (because they were previously exported and
|
||||
didn't change)
|
||||
missing All files which were not exported because they were
|
||||
missing from the Photos library
|
||||
exif_updated When used with '--exiftool', all files on which
|
||||
exiftool updated the metadata
|
||||
touched When used with '--touch-file', all files where the
|
||||
date was touched
|
||||
converted_to_jpeg When used with '--convert-to-jpeg', all files which
|
||||
were converted to jpeg
|
||||
sidecar_json_written When used with '--sidecar json', all JSON sidecar
|
||||
files which were written
|
||||
sidecar_json_skipped When used with '--sidecar json' and '--update', all
|
||||
JSON sidecar files which were skipped
|
||||
sidecar_exiftool_written When used with '--sidecar exiftool', all exiftool
|
||||
sidecar files which were written
|
||||
sidecar_exiftool_skipped When used with '--sidecar exiftool' and '--update,
|
||||
all exiftool sidecar files which were skipped
|
||||
sidecar_xmp_written When used with '--sidecar xmp', all XMP sidecar
|
||||
files which were written
|
||||
sidecar_xmp_skipped When used with '--sidecar xmp' and '--update', all
|
||||
XMP sidecar files which were skipped
|
||||
error All files which produced an error during export
|
||||
|
||||
In addition to all normal template fields, the template fields '{filepath}' and
|
||||
'{export_dir}' will be available to your command template. Both of these are
|
||||
path-type templates which means their various parts can be accessed using the
|
||||
available properties, e.g. '{filepath.name}' provides just the file name without
|
||||
path and '{filepath.suffix}' is the file extension (suffix) of the file. When
|
||||
using paths in your command template, it is important to properly quote the
|
||||
paths as they will be passed to the shell and path names may contain spaces.
|
||||
Both the '{shell_quote}' template and the '|shell_quote' template filter are
|
||||
available for this purpose. For example, the following command outputs the full
|
||||
path of newly exported files to file 'new.txt':
|
||||
|
||||
--post-command new "echo {filepath.name|shell_quote} >> {shell_quote,{export_dir}/exported.txt}"
|
||||
|
||||
In the above command, the 'shell_quote' filter is used to ensure
|
||||
'{filepath.name}' is properly quoted and the '{shell_quote}' template ensures
|
||||
the constructed path of '{exported_dir}/exported.txt' is properly quoted. If
|
||||
'{filepath.name}' is 'IMG 1234.jpeg' and '{export_dir}' is '/Volumes/Photo
|
||||
Export', the command thus renders to:
|
||||
|
||||
echo 'IMG 1234.jpeg' >> '/Volumes/Photo Export/exported.txt'
|
||||
|
||||
It is highly recommended that you run osxphotos with '--dry-run --verbose' first
|
||||
to ensure your commands are as expected. This will not actually run the commands
|
||||
but will print out the exact command string which would be executed.
|
||||
|
||||
|
||||
|
||||
|
||||
```
|
||||
<!-- OSXPHOTOS-EXPORT-USAGE:END -->
|
||||
|
||||
@@ -3047,6 +3170,7 @@ Valid filters are:
|
||||
- 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
|
||||
<!-- OSXPHOTOS-FILTER-TABLE:END -->
|
||||
|
||||
@@ -3223,7 +3347,7 @@ The following template field substitutions are availabe for use the templating s
|
||||
|{lf}|A line feed: '\n', alias for {newline}|
|
||||
|{cr}|A carriage return: '\r'|
|
||||
|{crlf}|a carriage return + line feed: '\r\n'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.42.34'|
|
||||
|{osxphotos_version}|The osxphotos version, e.g. '0.42.35'|
|
||||
|{osxphotos_cmd_line}|The full command line used to run osxphotos|
|
||||
|{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|
|
||||
@@ -3238,6 +3362,7 @@ The following template field substitutions are availabe for use the templating s
|
||||
|{searchinfo.venue}|Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|
||||
|{searchinfo.venue_type}|Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|
||||
|{photo}|Provides direct access to the PhotoInfo object for the photo. Must be used in format '{photo.property}' where 'property' represents a PhotoInfo property. For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. '{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.|
|
||||
|{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.|
|
||||
|{function}|Execute a python function from an external file and use return value as template substitution. Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. The function will be passed the PhotoInfo object for the photo. See https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py for an example of how to implement a template function.|
|
||||
<!-- OSXPHOTOS-TEMPLATE-TABLE:END -->
|
||||
|
||||
|
||||
@@ -315,6 +315,40 @@ Then the next to you run osxphotos, you can simply do this:
|
||||
|
||||
The configuration file is a plain text file in [TOML](https://toml.io/en/) format so the `.toml` extension is standard but you can name the file anything you like.
|
||||
|
||||
### Run commands on exported photos for post-processing
|
||||
|
||||
You can use the `--post-command` option to run one or more commands against exported files. The `--post-command` option takes two arguments: CATEGORY and COMMAND. CATEGORY is a string that describes which category of file to run the command against. The available categories are described in the help text available via: `osxphotos help export`. For example, the `exported` category includes all exported photos and the `skipped` category includes all photos that were skipped when running export with `--update`. COMMAND is an osxphotos template string which will be rendered then passed to the shell for execution.
|
||||
|
||||
For example, the following command generates a log of all exported files and their associated keywords:
|
||||
|
||||
`osxphotos export /path/to/export --post-command exported "echo {shell_quote,{filepath}{comma}{,+keyword,}} >> {shell_quote,{export_dir}/exported.txt}"`
|
||||
|
||||
The special template field `{shell_quote}` ensures a string is properly quoted for execution in the shell. For example, it's possible that a file path or keyword in this example has a space in the value and if not properly quoted, this would cause an error in the execution of the command. When running commands, the template `{filepath}` is set to the full path of the exported file and `{export_dir}` is set to the full path of the base export directory.
|
||||
|
||||
Explanation of the template string:
|
||||
|
||||
```txt
|
||||
{shell_quote,{filepath}{comma}{,+keyword,}}
|
||||
│ │ │ │ │
|
||||
│ │ │ | │
|
||||
└──> quote everything after comma for proper execution in the shell
|
||||
│ │ │ │
|
||||
└───> filepath of the exported file
|
||||
│ │ │
|
||||
└───> insert a comma
|
||||
│ │
|
||||
└───> join the list of keywords together with a ","
|
||||
│
|
||||
└───> if no keywords, insert nothing (empty string: "")
|
||||
```
|
||||
|
||||
Another example: if you had `exiftool` installed and wanted to wipe all metadata from all exported files, you could use the following:
|
||||
|
||||
`osxphotos export /path/to/export --post-command exported "/usr/local/bin/exiftool -all= {filepath|shell_quote}"`
|
||||
|
||||
This command uses the `|shell_quote` template filter instead of the `{shell_quote}` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text.
|
||||
|
||||
|
||||
### An example from an actual osxphotos user
|
||||
|
||||
Here's a comprehensive use case from an actual osxphotos user that integrates many of the concepts discussed in this tutorial (thank-you Philippe for contributing this!):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from ._version import __version__
|
||||
from .photoinfo import PhotoInfo
|
||||
from .exiftool import ExifTool
|
||||
from .photoinfo import ExportResults, PhotoInfo
|
||||
from .photosdb import PhotosDB
|
||||
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
|
||||
from .phototemplate import PhotoTemplate
|
||||
@@ -7,5 +8,4 @@ from .queryoptions import QueryOptions
|
||||
from .utils import _debug, _get_logger, _set_debug
|
||||
|
||||
# TODO: Add test for imageTimeZoneOffsetSeconds = None
|
||||
# TODO: Add test for __str__ and to_json
|
||||
# TODO: Add special albums and magic albums
|
||||
|
||||
@@ -220,3 +220,24 @@ BURST_KEY = 0b10000 # 16: burst image is the key photo (top of burst stack)
|
||||
BURST_UNKNOWN = 0b100000 # 32: this is almost always set with BURST_DEFAULT_PICK and never if BURST_DEFAULT_PICK is not set. I think this has something to do with what algorithm Photos used to pick the default image
|
||||
|
||||
LIVE_VIDEO_EXTENSIONS = [".mov"]
|
||||
|
||||
# categories that --post-command can be used with; these map to ExportResults fields
|
||||
POST_COMMAND_CATEGORIES = {
|
||||
"exported": "All exported files",
|
||||
"new": "When used with '--update', all newly exported files",
|
||||
"updated": "When used with '--update', all files which were previously exported but updated this time",
|
||||
"skipped": "When used with '--update', all files which were skipped (because they were previously exported and didn't change)",
|
||||
"missing": "All files which were not exported because they were missing from the Photos library",
|
||||
"exif_updated": "When used with '--exiftool', all files on which exiftool updated the metadata",
|
||||
"touched": "When used with '--touch-file', all files where the date was touched",
|
||||
"converted_to_jpeg": "When used with '--convert-to-jpeg', all files which were converted to jpeg",
|
||||
"sidecar_json_written": "When used with '--sidecar json', all JSON sidecar files which were written",
|
||||
"sidecar_json_skipped": "When used with '--sidecar json' and '--update', all JSON sidecar files which were skipped",
|
||||
"sidecar_exiftool_written": "When used with '--sidecar exiftool', all exiftool sidecar files which were written",
|
||||
"sidecar_exiftool_skipped": "When used with '--sidecar exiftool' and '--update, all exiftool sidecar files which were skipped",
|
||||
"sidecar_xmp_written": "When used with '--sidecar xmp', all XMP sidecar files which were written",
|
||||
"sidecar_xmp_skipped": "When used with '--sidecar xmp' and '--update', all XMP sidecar files which were skipped",
|
||||
"error": "All files which produced an error during export",
|
||||
# "deleted_files": "When used with '--cleanup', all files deleted during the export",
|
||||
# "deleted_directories": "When used with '--cleanup', all directories deleted during the export",
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.34"
|
||||
__version__ = "0.42.35"
|
||||
|
||||
@@ -7,6 +7,8 @@ import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import pprint
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -31,10 +33,10 @@ from ._constants import (
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||
OSXPHOTOS_EXPORT_DB,
|
||||
OSXPHOTOS_URL,
|
||||
POST_COMMAND_CATEGORIES,
|
||||
SIDECAR_EXIFTOOL,
|
||||
SIDECAR_JSON,
|
||||
SIDECAR_XMP,
|
||||
UNICODE_FORMAT,
|
||||
)
|
||||
from ._version import __version__
|
||||
from .cli_help import ExportCommand
|
||||
@@ -51,7 +53,7 @@ from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
|
||||
from .photoinfo import ExportResults
|
||||
from .photokit import check_photokit_authorization, request_photokit_authorization
|
||||
from .photosalbum import PhotosAlbum
|
||||
from .phototemplate import RenderOptions
|
||||
from .phototemplate import PhotoTemplate, RenderOptions
|
||||
from .queryoptions import QueryOptions
|
||||
from .utils import get_preferred_uti_extension
|
||||
|
||||
@@ -911,6 +913,19 @@ def cli(ctx, db, json_, debug):
|
||||
"This only works if the Photos library being exported is the last-opened (default) library in Photos. "
|
||||
"This feature is currently experimental. I don't know how well it will work on large export sets.",
|
||||
)
|
||||
@click.option(
|
||||
"--post-command",
|
||||
metavar="CATEGORY COMMAND",
|
||||
nargs=2,
|
||||
type=(click.Choice(POST_COMMAND_CATEGORIES, case_sensitive=False), str),
|
||||
multiple=True,
|
||||
help="Run COMMAND on exported files of category CATEGORY. CATEGORY can be one of: "
|
||||
f"{', '.join(list(POST_COMMAND_CATEGORIES.keys()))}. "
|
||||
"COMMAND is an osxphotos template string, for example: '--post-command exported \"echo {filepath|shell_quote} >> {export_dir}/exported.txt\"', "
|
||||
"which appends the full path of all exported files to the file 'exported.txt'. "
|
||||
"You can run more than one command by repeating the '--post-command' option with different arguments. "
|
||||
"See Post Command below.",
|
||||
)
|
||||
@click.option(
|
||||
"--exportdb",
|
||||
metavar="EXPORTDB_FILE",
|
||||
@@ -1075,6 +1090,7 @@ def export(
|
||||
regex,
|
||||
query_eval,
|
||||
duplicate,
|
||||
post_command,
|
||||
):
|
||||
"""Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1230,6 +1246,7 @@ def export(
|
||||
regex = cfg.regex
|
||||
query_eval = cfg.query_eval
|
||||
duplicate = cfg.duplicate
|
||||
post_command = cfg.post_command
|
||||
|
||||
# config file might have changed verbose
|
||||
VERBOSE = bool(verbose)
|
||||
@@ -1633,6 +1650,15 @@ def export(
|
||||
export_dir=dest,
|
||||
)
|
||||
|
||||
run_post_command(
|
||||
photo=p,
|
||||
post_command=post_command,
|
||||
export_results=export_results,
|
||||
export_dir=dest,
|
||||
dry_run=dry_run,
|
||||
exiftool_path=exiftool_path,
|
||||
)
|
||||
|
||||
if album_export and export_results.exported:
|
||||
try:
|
||||
album_export.add(p)
|
||||
@@ -3275,6 +3301,46 @@ def write_extended_attributes(
|
||||
return list(written), [f for f in skipped if f not in written]
|
||||
|
||||
|
||||
def run_post_command(
|
||||
photo, post_command, export_results, export_dir, dry_run, exiftool_path
|
||||
):
|
||||
# todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?)
|
||||
# todo: need a shell_quote template type:
|
||||
# {shell_quote,{filepath}/foo/bar}
|
||||
# that quotes everything in the default value
|
||||
for category, command_template in post_command:
|
||||
files = getattr(export_results, category)
|
||||
for f in files:
|
||||
# some categories, like error, return a tuple of (file, error str)
|
||||
if isinstance(f, tuple):
|
||||
f = f[0]
|
||||
render_options = RenderOptions(export_dir=export_dir, filepath=f)
|
||||
template = PhotoTemplate(photo, exiftool_path=exiftool_path)
|
||||
command, _ = template.render(command_template, options=render_options)
|
||||
command = command[0] if command else None
|
||||
if command:
|
||||
verbose_(f'Running command: "{command}"')
|
||||
if not dry_run:
|
||||
args = shlex.split(command)
|
||||
cwd = pathlib.Path(f).parent
|
||||
run_error = None
|
||||
run_results = None
|
||||
try:
|
||||
run_results = subprocess.run(command, shell=True, cwd=cwd)
|
||||
except Exception as e:
|
||||
run_error = e
|
||||
finally:
|
||||
run_error = run_error or run_results.returncode
|
||||
if run_error:
|
||||
click.echo(
|
||||
click.style(
|
||||
f'Error running command "{command}": {run_error}',
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
|
||||
|
||||
@cli.command(hidden=True)
|
||||
@DB_OPTION
|
||||
@DB_ARGUMENT
|
||||
|
||||
@@ -12,6 +12,7 @@ from ._constants import (
|
||||
EXTENDED_ATTRIBUTE_NAMES,
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||
OSXPHOTOS_EXPORT_DB,
|
||||
POST_COMMAND_CATEGORIES,
|
||||
)
|
||||
from .phototemplate import (
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
@@ -21,6 +22,7 @@ from .phototemplate import (
|
||||
)
|
||||
|
||||
|
||||
# TODO: The following help text could probably be done as mako template
|
||||
class ExportCommand(click.Command):
|
||||
"""Custom click.Command that overrides get_help() to show additional help info for export"""
|
||||
|
||||
@@ -175,11 +177,13 @@ The following attributes may be used with '--xattr-template':
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
"For example, if the field {export_dir} is '/Shared/Backup/Photos':\n")
|
||||
"For example, if the field {export_dir} is '/Shared/Backup/Photos':\n"
|
||||
)
|
||||
formatter.write("{export_dir.parent} is '/Shared/Backup'\n")
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
"If the field {filepath} is '/Shared/Backup/Photos/IMG_1234.JPG':\n")
|
||||
"If the field {filepath} is '/Shared/Backup/Photos/IMG_1234.JPG':\n"
|
||||
)
|
||||
formatter.write("{filepath.parent} is '/Shared/Backup/Photos'\n")
|
||||
formatter.write("{filepath.name} is 'IMG_1234.JPG'\n")
|
||||
formatter.write("{filepath.stem} is 'IMG_1234'\n")
|
||||
@@ -190,6 +194,54 @@ The following attributes may be used with '--xattr-template':
|
||||
|
||||
formatter.write_dl(templ_tuples)
|
||||
|
||||
formatter.write("\n\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Post Command **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write_text(
|
||||
"You can run commands on the exported photos for post-processing "
|
||||
+ "using the '--post-command' option. '--post-command' is passed a CATEGORY and a COMMAND. "
|
||||
+ "COMMAND is an osxphotos template string which will be rendered and passed to the shell "
|
||||
+ "for execution. CATEGORY is the category of file to pass to COMMAND. "
|
||||
+ "The following categories are available: "
|
||||
)
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Catgory", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in POST_COMMAND_CATEGORIES.items())
|
||||
formatter.write_dl(templ_tuples)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"In addition to all normal template fields, the template fields "
|
||||
+ "'{filepath}' and '{export_dir}' will be available to your command template. "
|
||||
+ "Both of these are path-type templates which means their various parts can be accessed using "
|
||||
+ "the available properties, e.g. '{filepath.name}' provides just the file name without path "
|
||||
+ "and '{filepath.suffix}' is the file extension (suffix) of the file. "
|
||||
+ "When using paths in your command template, it is important to properly quote the paths "
|
||||
+ "as they will be passed to the shell and path names may contain spaces. "
|
||||
+ "Both the '{shell_quote}' template and the '|shell_quote' template filter are available for "
|
||||
+ "this purpose. For example, the following command outputs the full path of newly exported files to file 'new.txt': "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
'--post-command new "echo {filepath.name|shell_quote} >> {shell_quote,{export_dir}/exported.txt}"'
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write_text(
|
||||
"In the above command, the 'shell_quote' filter is used to ensure '{filepath.name}' is properly quoted "
|
||||
+ "and the '{shell_quote}' template ensures the constructed path of '{exported_dir}/exported.txt' is properly quoted. "
|
||||
"If '{filepath.name}' is 'IMG 1234.jpeg' and '{export_dir}' is '/Volumes/Photo Export', the command "
|
||||
"thus renders to: "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write("echo 'IMG 1234.jpeg' >> '/Volumes/Photo Export/exported.txt'")
|
||||
formatter.write("\n\n")
|
||||
formatter.write_text(
|
||||
"It is highly recommended that you run osxphotos with '--dry-run --verbose' "
|
||||
+ "first to ensure your commands are as expected. This will not actually run the commands but will "
|
||||
+ "print out the exact command string which would be executed."
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
|
||||
help_text += formatter.getvalue()
|
||||
return help_text
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ Valid filters are:
|
||||
- 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
|
||||
<!-- OSXPHOTOS-FILTER-TABLE:END -->
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import locale
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import shlex
|
||||
|
||||
from textx import TextXSyntaxError, metamodel_from_file
|
||||
|
||||
@@ -173,6 +174,7 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
+ "For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. "
|
||||
+ "'{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of "
|
||||
+ "the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
|
||||
"{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.",
|
||||
"{function}": "Execute a python function from an external file and use return value as template substitution. "
|
||||
+ "Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. "
|
||||
+ "The function will be passed the PhotoInfo object for the photo. "
|
||||
@@ -188,6 +190,7 @@ FILTER_VALUES = {
|
||||
"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",
|
||||
}
|
||||
|
||||
@@ -248,6 +251,7 @@ class RenderOptions:
|
||||
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
|
||||
export_dir: set to the export directory if you want to evalute {export_dir} template
|
||||
filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
|
||||
quote: quote path templates for execution in the shell
|
||||
"""
|
||||
|
||||
none_str: str = "_"
|
||||
@@ -260,6 +264,7 @@ class RenderOptions:
|
||||
edited_version: bool = False
|
||||
export_dir: Optional[str] = None
|
||||
filepath: Optional[str] = None
|
||||
quote: bool = False
|
||||
|
||||
|
||||
class PhotoTemplateParser:
|
||||
@@ -320,6 +325,7 @@ class PhotoTemplate:
|
||||
self.strip = options.strip
|
||||
self.export_dir = options.export_dir
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
|
||||
def render(
|
||||
self,
|
||||
@@ -349,6 +355,7 @@ class PhotoTemplate:
|
||||
self.strip = options.strip
|
||||
self.export_dir = options.export_dir
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
|
||||
try:
|
||||
model = self.parser.parse(template)
|
||||
@@ -501,8 +508,7 @@ class PhotoTemplate:
|
||||
)
|
||||
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
|
||||
vals = self.get_template_value_multi(
|
||||
field,
|
||||
path_sep=path_sep,
|
||||
field, path_sep=path_sep, default=default
|
||||
)
|
||||
elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS:
|
||||
vals = self.get_template_value_pathlib(field)
|
||||
@@ -952,7 +958,7 @@ class PhotoTemplate:
|
||||
except AttributeError:
|
||||
raise ValueError(f"Unknown path-like field: {field_stem}")
|
||||
|
||||
value = _get_pathlib_value(field, field_value)
|
||||
value = _get_pathlib_value(field, field_value, self.quote)
|
||||
|
||||
if self.filename:
|
||||
value = sanitize_pathpart(value)
|
||||
@@ -1002,22 +1008,24 @@ class PhotoTemplate:
|
||||
value = ["[" + v + "]" for v in values]
|
||||
else:
|
||||
value = ["[" + values + "]"] if values else []
|
||||
elif filter_ == "shell_quote":
|
||||
if values and type(values) == list:
|
||||
value = [shlex.quote(v) for v in values]
|
||||
else:
|
||||
value = [shlex.quote(values)] if values else []
|
||||
elif filter_.startswith("function:"):
|
||||
value = self.get_template_value_filter_function(filter_, values)
|
||||
else:
|
||||
value = []
|
||||
return value
|
||||
|
||||
def get_template_value_multi(
|
||||
self,
|
||||
field,
|
||||
path_sep,
|
||||
):
|
||||
def get_template_value_multi(self, field, path_sep, default):
|
||||
"""lookup value for template field (multi-value template substitutions)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
path_sep: path separator to use for folder_album field
|
||||
default: value of default field
|
||||
|
||||
Returns:
|
||||
List of the matching template values or [].
|
||||
@@ -1084,6 +1092,8 @@ class PhotoTemplate:
|
||||
values = (
|
||||
self.photo.search_info.venue_types if self.photo.search_info else []
|
||||
)
|
||||
elif field == "shell_quote":
|
||||
values = [shlex.quote(v) for v in default if v]
|
||||
elif field.startswith("photo"):
|
||||
# provide access to PhotoInfo object
|
||||
properties = field.split(".")
|
||||
@@ -1292,12 +1302,18 @@ def get_template_help():
|
||||
return md
|
||||
|
||||
|
||||
def _get_pathlib_value(field, value):
|
||||
"""Get the value for a pathlib.Path type template"""
|
||||
def _get_pathlib_value(field, value, quote):
|
||||
"""Get the value for a pathlib.Path type template
|
||||
|
||||
Args:
|
||||
field: the path field, e.g. "filename.stem"
|
||||
value: the value for the path component
|
||||
quote: bool; if true, quotes the returned path for safe execution in the shell
|
||||
"""
|
||||
parts = field.split(".")
|
||||
|
||||
if len(parts) == 1:
|
||||
return value
|
||||
return shlex.quote(value) if quote else value
|
||||
|
||||
if len(parts) > 2:
|
||||
raise ValueError(f"Illegal value for path template: {field}")
|
||||
@@ -1307,6 +1323,9 @@ def _get_pathlib_value(field, value):
|
||||
path = pathlib.Path(value)
|
||||
try:
|
||||
val = getattr(path, attribute)
|
||||
return str(val)
|
||||
val_str = str(val)
|
||||
if quote:
|
||||
val_str = shlex.quote(val_str)
|
||||
return val_str
|
||||
except AttributeError:
|
||||
raise ValueError("Illegal value for path template: {attribute}")
|
||||
|
||||
@@ -6363,3 +6363,83 @@ def test_export_filepath_template():
|
||||
assert exifdata[0]["XMP:Description"] == os.path.join(
|
||||
isolated_cwd, CLI_TEMPLATE_FILENAME
|
||||
)
|
||||
|
||||
|
||||
def test_export_post_command():
|
||||
"""Test --post-command"""
|
||||
import os.path
|
||||
from osxphotos.cli import cli
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"--post-command",
|
||||
"exported",
|
||||
"echo {filepath.name|shell_quote} >> {export_dir}/exported.txt",
|
||||
"--name",
|
||||
"Park",
|
||||
"--skip-original-if-edited",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
with open("exported.txt") as f:
|
||||
lines = [line.strip() for line in f]
|
||||
assert lines[0] == "St James Park_edited.jpeg"
|
||||
|
||||
# run again with --update to test skipped
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"--post-command",
|
||||
"skipped",
|
||||
"echo {filepath.name|shell_quote} >> {export_dir}/skipped.txt",
|
||||
"--name",
|
||||
"Park",
|
||||
"--skip-original-if-edited",
|
||||
"--update",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
with open("skipped.txt") as f:
|
||||
lines = [line.strip() for line in f]
|
||||
assert lines[0] == "St James Park_edited.jpeg"
|
||||
|
||||
|
||||
def test_export_post_command_bad_command():
|
||||
"""Test --post-command with bad command"""
|
||||
import os.path
|
||||
from osxphotos.cli import cli
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"--post-command",
|
||||
"exported",
|
||||
"foobar {filepath.name|shell_quote} >> {export_dir}/exported.txt",
|
||||
"--name",
|
||||
"Park",
|
||||
"--skip-original-if-edited",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert 'Error running command "foobar' in result.output
|
||||
|
||||
@@ -66,6 +66,7 @@ TEMPLATE_VALUES_MULTI_KEYWORDS = {
|
||||
"{keyword|lower}": ["flowers", "wedding"],
|
||||
"{keyword|titlecase}": ["Flowers", "Wedding"],
|
||||
"{keyword|capitalize}": ["Flowers", "Wedding"],
|
||||
"{keyword|shell_quote}": ["flowers", "wedding"],
|
||||
"{+keyword}": ["flowerswedding"],
|
||||
"{+keyword|titlecase}": ["Flowerswedding"],
|
||||
"{+keyword|capitalize}": ["Flowerswedding"],
|
||||
@@ -81,6 +82,7 @@ TEMPLATE_VALUES_TITLE = {
|
||||
"{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|titlecase|lower|upper|shell_quote}": ["'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"],
|
||||
@@ -90,6 +92,7 @@ TEMPLATE_VALUES_TITLE = {
|
||||
"{+title}": ["Tulips tied together at a flower shop"],
|
||||
"{,+title}": ["Tulips tied together at a flower shop"],
|
||||
"{, +title}": ["Tulips tied together at a flower shop"],
|
||||
"{title|shell_quote}": ["'Tulips tied together at a flower shop'"],
|
||||
}
|
||||
|
||||
# Boolean type values that render to True
|
||||
@@ -385,7 +388,7 @@ def test_lookup_multi(photosdb_places):
|
||||
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
|
||||
if subst in ["{exiftool}", "{photo}", "{function}"]:
|
||||
continue
|
||||
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep)
|
||||
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep, default=[])
|
||||
assert isinstance(lookup, list)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user