Added --post-command, implements #443
This commit is contained in:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user