365 lines
10 KiB
Python
365 lines
10 KiB
Python
import csv
|
|
import json
|
|
import sys
|
|
|
|
import click
|
|
import yaml
|
|
|
|
import osxphotos
|
|
|
|
# TODO: add "--any" to search any field (e.g. keyword, description, name contains "wedding") (add case insensitive option)
|
|
|
|
|
|
class CLI_Obj:
|
|
def __init__(self, db=None, json=False, debug=False):
|
|
if debug:
|
|
osxphotos._debug(True)
|
|
self.photosdb = osxphotos.PhotosDB(dbfile=db)
|
|
self.json = json
|
|
|
|
|
|
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
|
|
|
|
|
|
@click.group(context_settings=CTX_SETTINGS)
|
|
@click.option(
|
|
"--db",
|
|
required=False,
|
|
metavar="<Photos database path>",
|
|
default=None,
|
|
help="Specify database file",
|
|
)
|
|
@click.option(
|
|
"--json",
|
|
required=False,
|
|
is_flag=True,
|
|
default=False,
|
|
help="Print output in JSON format",
|
|
)
|
|
@click.option("--debug", required=False, is_flag=True, default=False, hidden=True)
|
|
@click.pass_context
|
|
def cli(ctx, db, json, debug):
|
|
ctx.obj = CLI_Obj(db=db, json=json, debug=debug)
|
|
|
|
|
|
@cli.command()
|
|
@click.pass_obj
|
|
def keywords(cli_obj):
|
|
""" print out keywords found in the Photos library"""
|
|
keywords = {"keywords": cli_obj.photosdb.keywords_as_dict()}
|
|
if cli_obj.json:
|
|
print(json.dumps(keywords))
|
|
else:
|
|
print(yaml.dump(keywords, sort_keys=False))
|
|
|
|
|
|
@cli.command()
|
|
@click.pass_obj
|
|
def albums(cli_obj):
|
|
""" print out albums found in the Photos library """
|
|
albums = {"albums": cli_obj.photosdb.albums_as_dict()}
|
|
if cli_obj.json:
|
|
print(json.dumps(albums))
|
|
else:
|
|
print(yaml.dump(albums, sort_keys=False))
|
|
|
|
|
|
@cli.command()
|
|
@click.pass_obj
|
|
def persons(cli_obj):
|
|
""" print out persons (faces) found in the Photos library """
|
|
persons = {"persons": cli_obj.photosdb.persons_as_dict()}
|
|
if cli_obj.json:
|
|
print(json.dumps(persons))
|
|
else:
|
|
print(yaml.dump(persons, sort_keys=False))
|
|
|
|
|
|
@cli.command()
|
|
@click.pass_obj
|
|
def info(cli_obj):
|
|
""" print out descriptive info of the Photos library database """
|
|
pdb = cli_obj.photosdb
|
|
info = {}
|
|
info["database_path"] = pdb.get_db_path()
|
|
info["database_version"] = pdb.get_db_version()
|
|
|
|
photos = pdb.photos()
|
|
info["photo_count"] = len(photos)
|
|
|
|
keywords = pdb.keywords_as_dict()
|
|
info["keywords_count"] = len(keywords)
|
|
info["keywords"] = keywords
|
|
|
|
albums = pdb.albums_as_dict()
|
|
info["albums_count"] = len(albums)
|
|
info["albums"] = albums
|
|
|
|
persons = pdb.persons_as_dict()
|
|
# handle empty person names (added by Photos 5.0+ when face detected but not identified)
|
|
noperson = "UNKNOWN"
|
|
if "" in persons:
|
|
if noperson in persons:
|
|
persons[noperson].append(persons[""])
|
|
else:
|
|
persons[noperson] = persons[""]
|
|
persons.pop("", None)
|
|
|
|
info["persons_count"] = len(persons)
|
|
info["persons"] = persons
|
|
|
|
if cli_obj.json:
|
|
print(json.dumps(info))
|
|
else:
|
|
print(yaml.dump(info, sort_keys=False))
|
|
|
|
|
|
@cli.command()
|
|
@click.pass_obj
|
|
def dump(cli_obj):
|
|
""" print list of all photos & associated info from the Photos library """
|
|
pdb = cli_obj.photosdb
|
|
photos = pdb.photos()
|
|
print_photo_info(photos, cli_obj.json)
|
|
|
|
|
|
@cli.command()
|
|
@click.option("--keyword", default=None, multiple=True, help="search for keyword(s)")
|
|
@click.option("--person", default=None, multiple=True, help="search for person(s)")
|
|
@click.option("--album", default=None, multiple=True, help="search for album(s)")
|
|
@click.option("--uuid", default=None, multiple=True, help="search for UUID(s)")
|
|
@click.option(
|
|
"--name", default=None, multiple=True, help="search for TEXT in name of photo"
|
|
)
|
|
@click.option("--no-name", is_flag=True, help="search for photos with no name")
|
|
@click.option(
|
|
"--description",
|
|
default=None,
|
|
multiple=True,
|
|
help="search for TEXT in description of photo",
|
|
)
|
|
@click.option(
|
|
"--no-description", is_flag=True, help="search for photos with no description"
|
|
)
|
|
@click.option(
|
|
"-i",
|
|
"--ignore-case",
|
|
is_flag=True,
|
|
help="case insensitive search for name or description. Does not apply to keyword, person, or album",
|
|
)
|
|
@click.option("--favorite", is_flag=True, help="search for photos marked favorite")
|
|
@click.option(
|
|
"--not-favorite", is_flag=True, help="search for photos not marked favorite"
|
|
)
|
|
@click.option("--hidden", is_flag=True, help="search for photos marked hidden")
|
|
@click.option("--not-hidden", is_flag=True, help="search for photos not marked hidden")
|
|
@click.option("--missing", is_flag=True, help="search for photos missing from disk")
|
|
@click.option(
|
|
"--not-missing",
|
|
is_flag=True,
|
|
help="search for photos present on disk (e.g. not missing)",
|
|
)
|
|
@click.option(
|
|
"--json",
|
|
required=False,
|
|
is_flag=True,
|
|
default=False,
|
|
help="Print output in JSON format",
|
|
)
|
|
@click.pass_obj
|
|
@click.pass_context
|
|
def query(
|
|
ctx,
|
|
cli_obj,
|
|
keyword,
|
|
person,
|
|
album,
|
|
uuid,
|
|
name,
|
|
no_name,
|
|
description,
|
|
no_description,
|
|
ignore_case,
|
|
json,
|
|
favorite,
|
|
not_favorite,
|
|
hidden,
|
|
not_hidden,
|
|
missing,
|
|
not_missing,
|
|
):
|
|
""" Query the Photos database using 1 or more search options\n
|
|
If more than one option is provided, they are treated as "AND"
|
|
(e.g. search for photos matching all options)
|
|
"""
|
|
|
|
# if no query terms, show help and return
|
|
if (
|
|
not keyword
|
|
and not person
|
|
and not album
|
|
and not uuid
|
|
and not name
|
|
and not no_name
|
|
and not description
|
|
and not no_description
|
|
and not favorite
|
|
and not not_favorite
|
|
and not hidden
|
|
and not not_hidden
|
|
and not missing
|
|
and not not_missing
|
|
):
|
|
print(cli.commands["query"].get_help(ctx))
|
|
return
|
|
elif favorite and not_favorite:
|
|
# can't search for both favorite and notfavorite
|
|
print(cli.commands["query"].get_help(ctx))
|
|
return
|
|
elif hidden and not_hidden:
|
|
# can't search for both hidden and nothidden
|
|
print(cli.commands["query"].get_help(ctx))
|
|
return
|
|
elif missing and not_missing:
|
|
# can't search for both missing and notmissing
|
|
print(cli.commands["query"].get_help(ctx))
|
|
return
|
|
elif name and no_name:
|
|
# can't search for both name and no_name
|
|
print(cli.commands["query"].get_help(ctx))
|
|
return
|
|
elif description and no_description:
|
|
# can't search for both description and no_description
|
|
print(cli.commands["query"].get_help(ctx))
|
|
return
|
|
else:
|
|
photos = cli_obj.photosdb.photos(
|
|
keywords=keyword, persons=person, albums=album, uuid=uuid
|
|
)
|
|
|
|
if name:
|
|
# search name field for text
|
|
# if more than one, find photos with all name values in in name
|
|
if ignore_case:
|
|
# case-insensitive
|
|
for n in name:
|
|
n = n.lower()
|
|
photos = [p for p in photos if p.name() and n in p.name().lower()]
|
|
else:
|
|
for n in name:
|
|
photos = [p for p in photos if p.name() and n in p.name()]
|
|
elif no_name:
|
|
photos = [p for p in photos if not p.name()]
|
|
|
|
if description:
|
|
# search description field for text
|
|
# if more than one, find photos with all name values in in description
|
|
if ignore_case:
|
|
# case-insensitive
|
|
for d in description:
|
|
d = d.lower()
|
|
photos = [
|
|
p
|
|
for p in photos
|
|
if p.description() and d in p.description().lower()
|
|
]
|
|
else:
|
|
for d in description:
|
|
photos = [
|
|
p for p in photos if p.description() and d in p.description()
|
|
]
|
|
elif no_description:
|
|
photos = [p for p in photos if not p.description()]
|
|
|
|
if favorite:
|
|
photos = [p for p in photos if p.favorite()]
|
|
elif not_favorite:
|
|
photos = [p for p in photos if not p.favorite()]
|
|
|
|
if hidden:
|
|
photos = [p for p in photos if p.hidden()]
|
|
elif not_hidden:
|
|
photos = [p for p in photos if not p.hidden()]
|
|
|
|
if missing:
|
|
photos = [p for p in photos if p.ismissing()]
|
|
elif not_missing:
|
|
photos = [p for p in photos if not p.ismissing()]
|
|
|
|
print_photo_info(photos, cli_obj.json or json)
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("topic", default=None, required=False, nargs=1)
|
|
@click.pass_context
|
|
def help(ctx, topic, **kw):
|
|
""" print help; for help on commands: help <command> """
|
|
if topic is None:
|
|
print(ctx.parent.get_help())
|
|
else:
|
|
print(cli.commands[topic].get_help(ctx))
|
|
|
|
|
|
def print_photo_info(photos, json=False):
|
|
if json:
|
|
dump = []
|
|
for p in photos:
|
|
dump.append(p.to_json())
|
|
print(f"[{', '.join(dump)}]")
|
|
else:
|
|
# dump as CSV
|
|
csv_writer = csv.writer(
|
|
sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
|
|
)
|
|
dump = []
|
|
# add headers
|
|
dump.append(
|
|
[
|
|
"uuid",
|
|
"filename",
|
|
"original_filename",
|
|
"date",
|
|
"description",
|
|
"name",
|
|
"keywords",
|
|
"albums",
|
|
"persons",
|
|
"path",
|
|
"ismissing",
|
|
"hasadjustments",
|
|
"favorite",
|
|
"hidden",
|
|
"latitude",
|
|
"longitude",
|
|
"path_edited",
|
|
]
|
|
)
|
|
for p in photos:
|
|
dump.append(
|
|
[
|
|
p.uuid(),
|
|
p.filename(),
|
|
p.original_filename(),
|
|
str(p.date()),
|
|
p.description(),
|
|
p.name(),
|
|
", ".join(p.keywords()),
|
|
", ".join(p.albums()),
|
|
", ".join(p.persons()),
|
|
p.path(),
|
|
p.ismissing(),
|
|
p.hasadjustments(),
|
|
p.favorite(),
|
|
p.hidden(),
|
|
p._latitude(),
|
|
p._longitude(),
|
|
p.path_edited(),
|
|
]
|
|
)
|
|
for row in dump:
|
|
csv_writer.writerow(row)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|