* Began refactoring for improving unicode handling * Added platform and unicode modules * Added tests for unicode utilities * Added tests for unicode utilities * Added tests for unicode utilities * Added tests for unicode utilities * Fixed unicode tests for linux * Fixed unicode tests for linux * Fixed duplicate alubm name with --add-to-album * Fixed test for linux * Fix for duplicate unicode kewyords, see #907, #1085
760 lines
27 KiB
Python
760 lines
27 KiB
Python
"""Common options & parameters for osxphotos CLI commands"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import functools
|
|
from textwrap import dedent
|
|
from typing import Any, Callable
|
|
|
|
import click
|
|
|
|
from ..platform import is_macos
|
|
from .common import OSXPHOTOS_HIDDEN, print_version
|
|
from .param_types import *
|
|
|
|
__all__ = [
|
|
"DB_ARGUMENT",
|
|
"DB_OPTION",
|
|
"DEBUG_OPTIONS",
|
|
"DELETED_OPTIONS",
|
|
"FIELD_OPTION",
|
|
"JSON_OPTION",
|
|
"QUERY_OPTIONS",
|
|
"THEME_OPTION",
|
|
"TIMESTAMP_OPTION",
|
|
"VERBOSE_OPTION",
|
|
"VERSION_OPTION",
|
|
]
|
|
|
|
|
|
def validate_selected(ctx, param, value):
|
|
""" "Validate photos are actually selected when --selected is used"""
|
|
|
|
if not value:
|
|
# --selected not used, just return
|
|
return value
|
|
|
|
# imports here to avoid conflict with linux port
|
|
# TODO: fix this once linux port is complete
|
|
import photoscript
|
|
from applescript import ScriptError
|
|
|
|
selection = None
|
|
with contextlib.suppress(ScriptError):
|
|
# ScriptError raised if selection made in edit mode or Smart Albums (on older versions of Photos)
|
|
selection = photoscript.PhotosLibrary().selection
|
|
|
|
if not selection:
|
|
click.echo(
|
|
dedent(
|
|
"""
|
|
--selected option used but no photos selected in Photos.
|
|
|
|
To select photos in Photos use one of the following methods:
|
|
|
|
- Select a single photo: Click the photo, or press the arrow keys to quickly navigate to and select the photo.
|
|
|
|
- Select a group of adjacent photos in a day: Click the first photo, then hold down the Shift key while you click the last photo.
|
|
You can also hold down Shift and press the arrow keys, or simply drag to enclose the photos within the selection rectangle.
|
|
|
|
- Select photos in a day that are not adjacent to each other: Hold down the Command key as you click each photo.
|
|
|
|
- Deselect specific photos: Hold down the Command key and click the photos you want to deselect.
|
|
|
|
- Deselect all photos: Click an empty space in the window (not a photo).
|
|
"""
|
|
),
|
|
err=True,
|
|
)
|
|
ctx.exit(1)
|
|
return value
|
|
|
|
|
|
def _param_memo(f: Callable[..., Any], param: click.Parameter) -> None:
|
|
"""Add param to the list of params for a click.Command
|
|
This is directly from the click source code and
|
|
the implementation is thus tightly coupled to click internals
|
|
"""
|
|
if isinstance(f, click.Command):
|
|
f.params.append(param)
|
|
else:
|
|
if not hasattr(f, "__click_params__"):
|
|
f.__click_params__ = [] # type: ignore
|
|
|
|
f.__click_params__.append(param) # type: ignore
|
|
|
|
|
|
def make_click_option_decorator(*params: click.Parameter) -> Callable[..., Any]:
|
|
"""Make a decorator for a click option from one or more click Parameter objects"""
|
|
|
|
def decorator(wrapped=None) -> Callable[..., Any]:
|
|
"""Function decorator to add option to a click command.
|
|
|
|
Args:
|
|
wrapped: function to decorate (this is normally passed automatically
|
|
"""
|
|
|
|
if wrapped is None:
|
|
return decorator
|
|
|
|
def _add_options(wrapped):
|
|
"""Add query options to wrapped function"""
|
|
for param in params:
|
|
_param_memo(wrapped, param)
|
|
return wrapped
|
|
|
|
return _add_options(wrapped)
|
|
|
|
return decorator
|
|
|
|
|
|
VERSION_CHECK_OPTION = click.option("--no-version-check", required=False, is_flag=True)
|
|
|
|
_DB_PARAMETER = click.Option(
|
|
["--library", "--db", "db"],
|
|
required=False,
|
|
metavar="PHOTOS_LIBRARY_PATH",
|
|
default=None,
|
|
help=(
|
|
"Specify path to Photos library. "
|
|
"If not provided, will attempt to find the library to use in the following order: "
|
|
"1. last opened library, 2. system library, 3. ~/Pictures/Photos Library.photoslibrary"
|
|
),
|
|
type=click.Path(exists=True),
|
|
)
|
|
|
|
DB_OPTION = make_click_option_decorator(_DB_PARAMETER)
|
|
|
|
DB_ARGUMENT = click.argument(
|
|
"photos_library",
|
|
nargs=-1,
|
|
type=DeprecatedPath(
|
|
exists=True,
|
|
deprecation_warning="The PHOTOS_LIBRARY argument is deprecated and "
|
|
"will be removed in a future version of osxphotos. "
|
|
"Use --library instead to specify the Photos Library path.",
|
|
),
|
|
)
|
|
|
|
_JSON_PARAMETER = click.Option(
|
|
["--json", "json_"],
|
|
required=False,
|
|
is_flag=True,
|
|
default=False,
|
|
help="Print output in JSON format.",
|
|
)
|
|
|
|
JSON_OPTION = make_click_option_decorator(_JSON_PARAMETER)
|
|
|
|
_FIELD_PARAMETER = click.Option(
|
|
["--field", "-f"],
|
|
metavar="FIELD TEMPLATE",
|
|
multiple=True,
|
|
nargs=2,
|
|
help="Output only specified custom fields. "
|
|
"FIELD is the name of the field and TEMPLATE is the template to use as the field value. "
|
|
"May be repeated to output multiple fields. "
|
|
"For example, to output photo uuid, name, and title: "
|
|
'`--field uuid "{uuid}" --field name "{original_name}" --field title "{title}"`.',
|
|
)
|
|
|
|
FIELD_OPTION = make_click_option_decorator(_FIELD_PARAMETER)
|
|
|
|
_DELETED_PARAMETERS = [
|
|
click.Option(
|
|
["--deleted"],
|
|
is_flag=True,
|
|
help="Include photos from the 'Recently Deleted' folder.",
|
|
),
|
|
click.Option(
|
|
["--deleted-only"],
|
|
is_flag=True,
|
|
help="Include only photos from the 'Recently Deleted' folder.",
|
|
),
|
|
]
|
|
|
|
DELETED_OPTIONS = make_click_option_decorator(*_DELETED_PARAMETERS)
|
|
|
|
# The following are used by the query command and by
|
|
# QUERY_OPTIONS to add the query options to other commands
|
|
# To add new query options, add them to _QUERY_OPTIONS as
|
|
# a click.Option, add them to osxphotos/photosdb/photosdb.py::PhotosDB.query(),
|
|
# and to osxphotos/query_options.py::QueryOptions
|
|
_QUERY_PARAMETERS_DICT = {
|
|
"--keyword": click.Option(
|
|
["--keyword"],
|
|
metavar="KEYWORD",
|
|
default=None,
|
|
multiple=True,
|
|
help="Search for photos with keyword KEYWORD. "
|
|
'If more than one keyword, treated as "OR", e.g. find photos matching any keyword',
|
|
),
|
|
"--no-keyword": click.Option(
|
|
["--no-keyword"],
|
|
is_flag=True,
|
|
help="Search for photos with no keyword.",
|
|
),
|
|
"--person": click.Option(
|
|
["--person"],
|
|
metavar="PERSON",
|
|
default=None,
|
|
multiple=True,
|
|
help="Search for photos with person PERSON. "
|
|
'If more than one person, treated as "OR", e.g. find photos matching any person',
|
|
),
|
|
"--album": click.Option(
|
|
["--album"],
|
|
metavar="ALBUM",
|
|
default=None,
|
|
multiple=True,
|
|
help="Search for photos in album ALBUM. "
|
|
'If more than one album, treated as "OR", e.g. find photos matching any album',
|
|
),
|
|
"--folder": click.Option(
|
|
["--folder"],
|
|
metavar="FOLDER",
|
|
default=None,
|
|
multiple=True,
|
|
help="Search for photos in an album in folder FOLDER. "
|
|
'If more than one folder, treated as "OR", e.g. find photos in any FOLDER. '
|
|
"Only searches top level folders (e.g. does not look at subfolders)",
|
|
),
|
|
"--name": click.Option(
|
|
["--name"],
|
|
metavar="FILENAME",
|
|
default=None,
|
|
multiple=True,
|
|
help="Search for photos with filename matching FILENAME. "
|
|
'If more than one --name options is specified, they are treated as "OR", '
|
|
"e.g. find photos matching any FILENAME. ",
|
|
),
|
|
"--uuid": click.Option(
|
|
["--uuid"],
|
|
metavar="UUID",
|
|
default=None,
|
|
multiple=True,
|
|
help="Search for photos with UUID(s). "
|
|
"May be repeated to include multiple UUIDs.",
|
|
),
|
|
"--uuid-from-file": click.Option(
|
|
["--uuid-from-file"],
|
|
metavar="FILE",
|
|
default=None,
|
|
multiple=False,
|
|
help="Search for photos with UUID(s) loaded from FILE. "
|
|
"Format is a single UUID per line. Lines preceded with # are ignored. "
|
|
"If FILE is '-', read UUIDs from stdin.",
|
|
type=PathOrStdin(exists=True),
|
|
),
|
|
"--title": click.Option(
|
|
["--title"],
|
|
metavar="TITLE",
|
|
default=None,
|
|
multiple=True,
|
|
help="Search for TITLE in title of photo.",
|
|
),
|
|
"--no-title": click.Option(
|
|
["--no-title"], is_flag=True, help="Search for photos with no title."
|
|
),
|
|
"--description": click.Option(
|
|
["--description"],
|
|
metavar="DESC",
|
|
default=None,
|
|
multiple=True,
|
|
help="Search for DESC in description of photo.",
|
|
),
|
|
"--no-description": click.Option(
|
|
["--no-description"],
|
|
is_flag=True,
|
|
help="Search for photos with no description.",
|
|
),
|
|
"--place": click.Option(
|
|
["--place"],
|
|
metavar="PLACE",
|
|
default=None,
|
|
multiple=True,
|
|
help="Search for PLACE in photo's reverse geolocation info",
|
|
),
|
|
"--no-place": click.Option(
|
|
["--no-place"],
|
|
is_flag=True,
|
|
help="Search for photos with no associated place name info (no reverse geolocation info)",
|
|
),
|
|
"--location": click.Option(
|
|
["--location"],
|
|
is_flag=True,
|
|
help="Search for photos with associated location info (e.g. GPS coordinates)",
|
|
),
|
|
"--no-location": click.Option(
|
|
["--no-location"],
|
|
is_flag=True,
|
|
help="Search for photos with no associated location info (e.g. no GPS coordinates)",
|
|
),
|
|
"--label": click.Option(
|
|
["--label"],
|
|
metavar="LABEL",
|
|
multiple=True,
|
|
help="Search for photos with image classification label LABEL (Photos 5+ only). "
|
|
'If more than one label, treated as "OR", e.g. find photos matching any label',
|
|
),
|
|
"--uti": click.Option(
|
|
["--uti"],
|
|
metavar="UTI",
|
|
default=None,
|
|
multiple=False,
|
|
help="Search for photos whose uniform type identifier (UTI) matches UTI",
|
|
),
|
|
"--ignore_case": click.Option(
|
|
["-i", "--ignore-case"],
|
|
is_flag=True,
|
|
help="Case insensitive search for title, description, place, keyword, person, or album.",
|
|
),
|
|
"--edited": click.Option(
|
|
["--edited"],
|
|
is_flag=True,
|
|
help="Search for photos that have been edited.",
|
|
),
|
|
"--not-edited": click.Option(
|
|
["--not-edited"],
|
|
is_flag=True,
|
|
help="Search for photos that have not been edited.",
|
|
),
|
|
"--external-edit": click.Option(
|
|
["--external-edit"],
|
|
is_flag=True,
|
|
help="Search for photos edited in external editor.",
|
|
),
|
|
"--favorite": click.Option(
|
|
["--favorite"], is_flag=True, help="Search for photos marked favorite."
|
|
),
|
|
"--not-favorite": click.Option(
|
|
["--not-favorite"],
|
|
is_flag=True,
|
|
help="Search for photos not marked favorite.",
|
|
),
|
|
"--hidden": click.Option(
|
|
["--hidden"], is_flag=True, help="Search for photos marked hidden."
|
|
),
|
|
"--not-hidden": click.Option(
|
|
["--not-hidden"],
|
|
is_flag=True,
|
|
help="Search for photos not marked hidden.",
|
|
),
|
|
"--shared": click.Option(
|
|
["--shared"],
|
|
is_flag=True,
|
|
help="Search for photos in shared iCloud album (Photos 5+ only).",
|
|
),
|
|
"--not-shared": click.Option(
|
|
["--not-shared"],
|
|
is_flag=True,
|
|
help="Search for photos not in shared iCloud album (Photos 5+ only).",
|
|
),
|
|
"--burst": click.Option(
|
|
["--burst"],
|
|
is_flag=True,
|
|
help="Search for photos that were taken in a burst.",
|
|
),
|
|
"--not-burst": click.Option(
|
|
["--not-burst"],
|
|
is_flag=True,
|
|
help="Search for photos that are not part of a burst.",
|
|
),
|
|
"--live": click.Option(
|
|
["--live"], is_flag=True, help="Search for Apple live photos"
|
|
),
|
|
"--not-live": click.Option(
|
|
["--not-live"],
|
|
is_flag=True,
|
|
help="Search for photos that are not Apple live photos.",
|
|
),
|
|
"--portrait": click.Option(
|
|
["--portrait"],
|
|
is_flag=True,
|
|
help="Search for Apple portrait mode photos.",
|
|
),
|
|
"--not-portrait": click.Option(
|
|
["--not-portrait"],
|
|
is_flag=True,
|
|
help="Search for photos that are not Apple portrait mode photos.",
|
|
),
|
|
"--screenshot": click.Option(
|
|
["--screenshot"], is_flag=True, help="Search for screenshot photos."
|
|
),
|
|
"--not-screenshot": click.Option(
|
|
["--not-screenshot"],
|
|
is_flag=True,
|
|
help="Search for photos that are not screenshot photos.",
|
|
),
|
|
"--slow-mo": click.Option(
|
|
["--slow-mo"], is_flag=True, help="Search for slow motion videos."
|
|
),
|
|
"--not-slow-mo": click.Option(
|
|
["--not-slow-mo"],
|
|
is_flag=True,
|
|
help="Search for photos that are not slow motion videos.",
|
|
),
|
|
"--time-lapse": click.Option(
|
|
["--time-lapse"], is_flag=True, help="Search for time lapse videos."
|
|
),
|
|
"--not-time-lapse": click.Option(
|
|
["--not-time-lapse"],
|
|
is_flag=True,
|
|
help="Search for photos that are not time lapse videos.",
|
|
),
|
|
"--hdr": click.Option(
|
|
["--hdr"],
|
|
is_flag=True,
|
|
help="Search for high dynamic range (HDR) photos.",
|
|
),
|
|
"--not-hdr": click.Option(
|
|
["--not-hdr"],
|
|
is_flag=True,
|
|
help="Search for photos that are not HDR photos.",
|
|
),
|
|
"--selfie": click.Option(
|
|
["--selfie"],
|
|
is_flag=True,
|
|
help="Search for selfies (photos taken with front-facing cameras).",
|
|
),
|
|
"--not-selfie": click.Option(
|
|
["--not-selfie"],
|
|
is_flag=True,
|
|
help="Search for photos that are not selfies.",
|
|
),
|
|
"--panorama": click.Option(
|
|
["--panorama"], is_flag=True, help="Search for panorama photos."
|
|
),
|
|
"--not-panorama": click.Option(
|
|
["--not-panorama"],
|
|
is_flag=True,
|
|
help="Search for photos that are not panoramas.",
|
|
),
|
|
"--has-raw": click.Option(
|
|
["--has-raw"],
|
|
is_flag=True,
|
|
help="Search for photos with both a jpeg and raw version",
|
|
),
|
|
"--only-movies": click.Option(
|
|
["--only-movies"],
|
|
is_flag=True,
|
|
help="Search only for movies (default searches both images and movies).",
|
|
),
|
|
"--only-photos": click.Option(
|
|
["--only-photos"],
|
|
is_flag=True,
|
|
help="Search only for photos/images (default searches both images and movies).",
|
|
),
|
|
"--from-date": click.Option(
|
|
["--from-date"],
|
|
help="Search by item start date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).",
|
|
type=DateTimeISO8601(),
|
|
),
|
|
"--to-date": click.Option(
|
|
["--to-date"],
|
|
help="Search by item end date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).",
|
|
type=DateTimeISO8601(),
|
|
),
|
|
"--from-time": click.Option(
|
|
["--from-time"],
|
|
help="Search by item start time of day, e.g. 12:00, or 12:00:00.",
|
|
type=TimeISO8601(),
|
|
),
|
|
"--to-time": click.Option(
|
|
["--to-time"],
|
|
help="Search by item end time of day, e.g. 12:00 or 12:00:00.",
|
|
type=TimeISO8601(),
|
|
),
|
|
"--year": click.Option(
|
|
["--year"],
|
|
metavar="YEAR",
|
|
help="Search for items from a specific year, e.g. --year 2022 to find all photos from the year 2022. "
|
|
"May be repeated to search multiple years.",
|
|
multiple=True,
|
|
type=int,
|
|
),
|
|
"--added-before": click.Option(
|
|
["--added-before"],
|
|
metavar="DATE",
|
|
help="Search for items added to the library before a specific date/time, "
|
|
"e.g. --added-before e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).",
|
|
type=DateTimeISO8601(),
|
|
),
|
|
"--added-after": click.Option(
|
|
["--added-after"],
|
|
metavar="DATE",
|
|
help="Search for items added to the libray after a specific date/time, "
|
|
"e.g. --added-after e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).",
|
|
type=DateTimeISO8601(),
|
|
),
|
|
"--added-in-last": click.Option(
|
|
["--added-in-last"],
|
|
metavar="TIME_DELTA",
|
|
help="Search for items added to the library in the last TIME_DELTA, "
|
|
"where TIME_DELTA is a string like "
|
|
"'12 hrs', '1 day', '1d', '1 week', '2weeks', '1 month', '1 year'. "
|
|
"for example, `--added-in-last 7d` and `--added-in-last '1 week'` are equivalent. "
|
|
"months are assumed to be 30 days and years are assumed to be 365 days. "
|
|
"Common English abbreviations are accepted, e.g. d, day, days or m, min, minutes.",
|
|
type=TimeOffset(),
|
|
),
|
|
"--has-comment": click.Option(
|
|
["--has-comment"],
|
|
is_flag=True,
|
|
help="Search for photos that have comments.",
|
|
),
|
|
"--no-comment": click.Option(
|
|
["--no-comment"],
|
|
is_flag=True,
|
|
help="Search for photos with no comments.",
|
|
),
|
|
"--has-likes": click.Option(
|
|
["--has-likes"], is_flag=True, help="Search for photos that have likes."
|
|
),
|
|
"--no-likes": click.Option(
|
|
["--no-likes"], is_flag=True, help="Search for photos with no likes."
|
|
),
|
|
"--is-reference": click.Option(
|
|
["--is-reference"],
|
|
is_flag=True,
|
|
help="Search for photos that were imported as referenced files (not copied into Photos library).",
|
|
),
|
|
"--not-reference": click.Option(
|
|
["--not-reference"],
|
|
is_flag=True,
|
|
help="Search for photos that are not references, that is, they were copied into the Photos library "
|
|
"and are managed by Photos.",
|
|
),
|
|
"--in-album": click.Option(
|
|
["--in-album"],
|
|
is_flag=True,
|
|
help="Search for photos that are in one or more albums.",
|
|
),
|
|
"--not-in-album": click.Option(
|
|
["--not-in-album"],
|
|
is_flag=True,
|
|
help="Search for photos that are not in any albums.",
|
|
),
|
|
"--duplicate": click.Option(
|
|
["--duplicate"],
|
|
is_flag=True,
|
|
help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, "
|
|
"evaluating date created, size, height, width, and edited status to find *possible* duplicates. "
|
|
"This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple "
|
|
"times or duplicated within Photos.",
|
|
),
|
|
"--min-size": click.Option(
|
|
["--min-size"],
|
|
metavar="SIZE",
|
|
type=BitMathSize(),
|
|
help="Search for photos with size >= SIZE bytes. "
|
|
"The size evaluated is the photo's original size (when imported to Photos). "
|
|
"Size may be specified as integer bytes or using SI or NIST units. "
|
|
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
|
|
),
|
|
"--max-size": click.Option(
|
|
["--max-size"],
|
|
metavar="SIZE",
|
|
type=BitMathSize(),
|
|
help="Search for photos with size <= SIZE bytes. "
|
|
"The size evaluated is the photo's original size (when imported to Photos). "
|
|
"Size may be specified as integer bytes or using SI or NIST units. "
|
|
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
|
|
),
|
|
"--missing": click.Option(
|
|
["--missing"], is_flag=True, help="Search for photos missing from disk."
|
|
),
|
|
"--not-missing": click.Option(
|
|
["--not-missing"],
|
|
is_flag=True,
|
|
help="Search for photos present on disk (e.g. not missing).",
|
|
),
|
|
"--cloudasset": click.Option(
|
|
["--cloudasset"],
|
|
is_flag=True,
|
|
help="Search for photos that are part of an iCloud library",
|
|
),
|
|
"--not-cloudasset": click.Option(
|
|
["--not-cloudasset"],
|
|
is_flag=True,
|
|
help="Search for photos that are not part of an iCloud library",
|
|
),
|
|
"--incloud": click.Option(
|
|
["--incloud"],
|
|
is_flag=True,
|
|
help="Search for photos that are in iCloud (have been synched)",
|
|
),
|
|
"--not-incloud": click.Option(
|
|
["--not-incloud"],
|
|
is_flag=True,
|
|
help="Search for photos that are not in iCloud (have not been synched)",
|
|
),
|
|
"--syndicated": click.Option(
|
|
["--syndicated"],
|
|
is_flag=True,
|
|
help="Search for photos that have been shared via syndication ('Shared with You' album via Messages, etc.)",
|
|
),
|
|
"--not-syndicated": click.Option(
|
|
["--not-syndicated"],
|
|
is_flag=True,
|
|
help="Search for photos that have not been shared via syndication ('Shared with You' album via Messages, etc.)",
|
|
),
|
|
"--saved-to-library": click.Option(
|
|
["--saved-to-library"],
|
|
is_flag=True,
|
|
help="Search for syndicated photos that have saved to the library",
|
|
),
|
|
"--not-saved-to-library": click.Option(
|
|
["--not-saved-to-library"],
|
|
is_flag=True,
|
|
help="Search for syndicated photos that have not saved to the library",
|
|
),
|
|
"--regex": click.Option(
|
|
["--regex"],
|
|
metavar="REGEX TEMPLATE",
|
|
nargs=2,
|
|
multiple=True,
|
|
help="Search for photos where TEMPLATE matches regular expression REGEX. "
|
|
"For example, to find photos in an album that begins with 'Beach': '--regex \"^Beach\" \"{album}\"'. "
|
|
"You may specify more than one regular expression match by repeating '--regex' with different arguments.",
|
|
),
|
|
"--selected": click.Option(
|
|
["--selected"],
|
|
is_flag=True,
|
|
help="Filter for photos that are currently selected in Photos.",
|
|
callback=validate_selected,
|
|
),
|
|
"--exif": click.Option(
|
|
["--exif"],
|
|
metavar="EXIF_TAG VALUE",
|
|
nargs=2,
|
|
multiple=True,
|
|
help="Search for photos where EXIF_TAG exists in photo's EXIF data and contains VALUE. "
|
|
"For example, to find photos created by Adobe Photoshop: `--exif Software 'Adobe Photoshop' `"
|
|
"or to find all photos shot on a Canon camera: `--exif Make Canon`. "
|
|
"EXIF_TAG can be any valid exiftool tag, with or without group name, e.g. `EXIF:Make` or `Make`. "
|
|
"To use --exif, exiftool must be installed and in the path.",
|
|
),
|
|
"--query-eval": click.Option(
|
|
["--query-eval"],
|
|
metavar="CRITERIA",
|
|
multiple=True,
|
|
help="Evaluate CRITERIA to filter photos. "
|
|
"CRITERIA will be evaluated in context of the following python list comprehension: "
|
|
"`photos = [photo for photo in photos if CRITERIA]` "
|
|
"where photo represents a PhotoInfo object. "
|
|
"For example: `--query-eval photo.favorite` returns all photos that have been "
|
|
"favorited and is equivalent to --favorite. "
|
|
"You may specify more than one CRITERIA by using --query-eval multiple times. "
|
|
"CRITERIA must be a valid python expression. "
|
|
"See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
|
|
),
|
|
"--query-function": click.Option(
|
|
["--query-function"],
|
|
metavar="filename.py::function",
|
|
multiple=True,
|
|
type=FunctionCall(),
|
|
help="Run function to filter photos. Use this in format: --query-function filename.py::function where filename.py is a python "
|
|
+ "file you've created and function is the name of the function in the python file you want to call. "
|
|
+ "Your function will be passed a list of PhotoInfo objects and is expected to return a filtered list of PhotoInfo objects. "
|
|
+ "You may use more than one function by repeating the --query-function option with a different value. "
|
|
+ "Your query function will be called after all other query options have been evaluated. "
|
|
+ "See https://github.com/RhetTbull/osxphotos/blob/master/examples/query_function.py for example of how to use this option.",
|
|
),
|
|
}
|
|
|
|
if not is_macos:
|
|
del _QUERY_PARAMETERS_DICT["--selected"]
|
|
|
|
|
|
def QUERY_OPTIONS(
|
|
wrapped=None, *, exclude: list[str] | None = None
|
|
) -> Callable[..., Any]:
|
|
"""Function decorator to add query options to a click command.
|
|
|
|
Args:
|
|
wrapped: function to decorate (this is normally passed automatically
|
|
exclude: list of query options to exclude from the command, for example `exclude=["--shared"]
|
|
"""
|
|
if wrapped is None:
|
|
return functools.partial(QUERY_OPTIONS, exclude=exclude)
|
|
|
|
exclude = exclude or []
|
|
|
|
def _add_options(wrapped):
|
|
"""Add query options to wrapped function"""
|
|
for option in reversed(_QUERY_PARAMETERS_DICT.keys()):
|
|
if option in exclude:
|
|
continue
|
|
click_opt = _QUERY_PARAMETERS_DICT[option]
|
|
_param_memo(wrapped, click_opt)
|
|
return wrapped
|
|
|
|
return _add_options(wrapped)
|
|
|
|
|
|
_DEBUG_PARAMETERS = [
|
|
click.Option(
|
|
["--debug"],
|
|
is_flag=True,
|
|
help="Enable debug output.",
|
|
hidden=OSXPHOTOS_HIDDEN,
|
|
),
|
|
click.Option(
|
|
["--watch"],
|
|
metavar="MODULE::NAME",
|
|
multiple=True,
|
|
help="Watch function or method calls. The function to watch must be in the form "
|
|
"MODULE::NAME where MODULE is the module path and NAME is the function or method name "
|
|
"contained in the module. For example, to watch all calls to FileUtil.copy() which is in "
|
|
"osxphotos.fileutil, use: "
|
|
"'--watch osxphotos.fileutil::FileUtil.copy'. More than one --watch option can be specified.",
|
|
hidden=OSXPHOTOS_HIDDEN,
|
|
),
|
|
click.Option(
|
|
["--breakpoint"],
|
|
metavar="MODULE::NAME",
|
|
multiple=True,
|
|
help="Add breakpoint to function calls. The function to watch must be in the form "
|
|
"MODULE::NAME where MODULE is the module path and NAME is the function or method name "
|
|
"contained in the module. For example, to set a breakpoint for calls to "
|
|
"FileUtil.copy() which is in osxphotos.fileutil, use: "
|
|
"'--breakpoint osxphotos.fileutil::FileUtil.copy'. More than one --breakpoint option can be specified.",
|
|
hidden=OSXPHOTOS_HIDDEN,
|
|
),
|
|
]
|
|
DEBUG_OPTIONS = make_click_option_decorator(*_DEBUG_PARAMETERS)
|
|
|
|
_THEME_PARAMETER = click.Option(
|
|
["--theme"],
|
|
metavar="THEME",
|
|
type=click.Choice(["dark", "light", "mono", "plain"], case_sensitive=False),
|
|
help="Specify the color theme to use for output. "
|
|
"Valid themes are 'dark', 'light', 'mono', and 'plain'. "
|
|
"Defaults to 'dark' or 'light' depending on system dark mode setting.",
|
|
)
|
|
THEME_OPTION = make_click_option_decorator(_THEME_PARAMETER)
|
|
|
|
_VERBOSE_PARAMETER = click.Option(
|
|
["--verbose", "-V", "verbose_flag"],
|
|
count=True,
|
|
help="Print verbose output; may be specified multiple times for more verbose output.",
|
|
)
|
|
VERBOSE_OPTION = make_click_option_decorator(_VERBOSE_PARAMETER)
|
|
|
|
_TIMESTAMP_PARAMETER = click.Option(
|
|
["--timestamp"], is_flag=True, help="Add time stamp to verbose output"
|
|
)
|
|
TIMESTAMP_OPTION = make_click_option_decorator(_TIMESTAMP_PARAMETER)
|
|
|
|
_VERSION_PARAMETER = click.Option(
|
|
["--version", "-v", "_version_flag"],
|
|
is_flag=True,
|
|
help="Show the version and exit.",
|
|
callback=print_version,
|
|
)
|
|
|
|
VERSION_OPTION = make_click_option_decorator(_VERSION_PARAMETER)
|