Initial implementation for Finder tags

This commit is contained in:
Rhet Turnbull 2020-12-30 08:32:42 -08:00
parent 5dccdf7750
commit 5885b23d32
4 changed files with 150 additions and 53 deletions

View File

@ -13,12 +13,14 @@ import unicodedata
import click
import yaml
import osxmetadata
import osxphotos
from ._constants import (
_EXIF_TOOL_URL,
_PHOTOS_4_VERSION,
_UNKNOWN_PLACE,
_OSXPHOTOS_NONE_SENTINEL,
CLI_COLOR_ERROR,
CLI_COLOR_WARNING,
DEFAULT_EDITED_SUFFIX,
@ -1454,6 +1456,22 @@ def query(
'--description-template "{descr} exported with osxphotos on {today.date}" '
"See Templating System below.",
)
@click.option(
"--finder-tag-template",
metavar="TEMPLATE",
multiple=True,
default=None,
help="Set Finder tags to TEMPLATE. These tags can be searched in the Finder or Spotlight with "
"'tag:tagname' format. For example, '--finder-tag-template \"{label}\"' to set Finder tags to photo labels. "
"You may specify multiple TEMPLATE values by using '--finder-tag-template' multiple times. "
"See also '--finder-tag-keywords'.",
)
@click.option(
"--finder-tag-keywords",
is_flag=True,
help="Set Finder tags to keywords; any keywords specified via '--keyword-template', '--person-keyword', etc. "
"will also be used as Finder tags. See also '--finder-tag-template'.",
)
@click.option(
"--directory",
metavar="DIRECTORY",
@ -1594,6 +1612,8 @@ def export(
album_keyword,
keyword_template,
description_template,
finder_tag_template,
finder_tag_keywords,
current_name,
convert_to_jpeg,
jpeg_quality,
@ -1730,6 +1750,8 @@ def export(
album_keyword = cfg.album_keyword
keyword_template = cfg.keyword_template
description_template = cfg.description_template
finder_tag_template = cfg.finder_tag_template
finder_tag_keywords = cfg.finder_tag_keywords
current_name = cfg.current_name
convert_to_jpeg = cfg.convert_to_jpeg
jpeg_quality = cfg.jpeg_quality
@ -1890,8 +1912,13 @@ def export(
not x for x in [skip_edited, skip_bursts, skip_live, skip_raw]
]
# verify exiftool installed and in path if path not provided
if (exiftool or exiftool_merge_keywords or exiftool_merge_persons) and not exiftool_path:
# verify exiftool installed and in path if path not provided and exiftool will be used
# NOTE: this won't catch use of {exiftool:} in a template
# but those will raise error during template eval if exiftool path not set
if (
any([exiftool, exiftool_merge_keywords, exiftool_merge_persons])
and not exiftool_path
):
try:
exiftool_path = get_exiftool_path()
except FileNotFoundError:
@ -1905,7 +1932,7 @@ def export(
)
ctx.exit(2)
if any([exiftool, exiftool_path, exiftool_merge_keywords, exiftool_merge_persons]):
if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]):
verbose_(f"exiftool path: {exiftool_path}")
isphoto = ismovie = True # default searches for everything
@ -2070,8 +2097,10 @@ def export(
original_name = not current_name
results = ExportResults()
if verbose:
for p in photos:
# send progress bar output to /dev/null if verbose to hide the progress bar
fp = open(os.devnull, "w") if verbose else None
with click.progressbar(photos, file=fp) as bar:
for p in bar:
export_results = export_photo(
photo=p,
dest=dest,
@ -2113,56 +2142,30 @@ def export(
)
results += export_results
# if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg":
# for photo_file in set(
# results.exported + results.updated + results.exif_updated
# ):
# verbose_(f"Converting {photo_file} to jpeg")
else:
# show progress bar
with click.progressbar(photos) as bar:
for p in bar:
export_results = export_photo(
photo=p,
dest=dest,
verbose=verbose,
export_by_date=export_by_date,
sidecar=sidecar,
sidecar_drop_ext=sidecar_drop_ext,
update=update,
ignore_signature=ignore_signature,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
export_edited=export_edited,
skip_original_if_edited=skip_original_if_edited,
original_name=original_name,
export_live=export_live,
download_missing=download_missing,
exiftool=exiftool,
exiftool_merge_keywords=exiftool_merge_keywords,
exiftool_merge_persons=exiftool_merge_persons,
directory=directory,
filename_template=filename_template,
export_raw=export_raw,
if finder_tag_keywords or finder_tag_template:
files = set(
export_results.exported
+ export_results.new
+ export_results.updated
+ export_results.exif_updated
+ export_results.converted_to_jpeg
+ export_results.skipped
)
tags_written, tags_skipped = write_finder_tags(
p,
files,
keywords=finder_tag_keywords,
keyword_template=keyword_template,
album_keyword=album_keyword,
person_keyword=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
touch_file=touch_file,
edited_suffix=edited_suffix,
original_suffix=original_suffix,
use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
exiftool_option=exiftool_option,
exiftool_merge_keywords=exiftool_merge_keywords,
finder_tag_template=finder_tag_template,
)
results += export_results
results.xattr_written.extend(tags_written)
results.xattr_skipped.extend(tags_skipped)
if fp is not None:
fp.close()
if cleanup:
all_files = (
@ -3206,6 +3209,8 @@ def write_export_report(report_file, results):
"error": 0,
"exiftool_warning": "",
"exiftool_error": "",
"extended_attributes_written": 0,
"extended_attributes_skipped": 0,
}
for result in results.all_files()
}
@ -3267,6 +3272,12 @@ def write_export_report(report_file, results):
for result in results.exiftool_error:
all_results[result[0]]["exiftool_error"] = result[1]
for result in results.xattr_written:
all_results[result]["extended_attributes_written"] = 1
for result in results.xattr_skipped:
all_results[result]["extended_attributes_skipped"] = 1
report_columns = [
"filename",
"exported",
@ -3283,6 +3294,8 @@ def write_export_report(report_file, results):
"error",
"exiftool_warning",
"exiftool_error",
"extended_attributes_written",
"extended_attributes_skipped",
]
try:
@ -3334,5 +3347,84 @@ def cleanup_files(dest_path, files_to_keep, fileutil):
return (deleted_files, deleted_dirs)
def write_finder_tags(
photo,
files,
keywords=False,
keyword_template=None,
album_keyword=None,
person_keyword=None,
exiftool_merge_keywords=None,
finder_tag_template=None,
):
""" Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written
Args:
photo: a PhotoInfo object
files: list of file paths to write Finder tags to
keywords: if True, sets Finder tags to all keywords including any evaluated from keyword_template, album_keyword, person_keyword, exiftool_merge_keywords
keyword_template: list of keyword templates to evaluate for determining keywords
album_keyword: if True, use album names as keywords
person_keyword: if True, use person in image as keywords
exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords
finder_tag_template: list of templates to evaluate for determining Finder tags
Returns:
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
"""
tags = []
written = []
skipped = []
if keywords:
# match whatever keywords would've been used in --exiftool or --sidecar
exif = photo._exiftool_dict(
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
merge_exif_keywords=exiftool_merge_keywords,
)
try:
if exif["IPTC:Keywords"]:
tags.extend(exif["IPTC:Keywords"])
except KeyError:
pass
if finder_tag_template:
rendered_tags = []
for template_str in finder_tag_template:
rendered, unmatched = photo.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
if unmatched:
click.echo(
click.style(
f"Warning: unmatched template substitution for template: {template_str} {unmatched}",
fg=CLI_COLOR_WARNING,
),
err=True,
)
rendered_tags.extend(rendered)
# filter out any template values that didn't match by looking for sentinel
rendered_tags = [
tag for tag in rendered_tags if _OSXPHOTOS_NONE_SENTINEL not in tag
]
tags.extend(rendered_tags)
tags = [osxmetadata.Tag(tag) for tag in set(tags)]
for f in files:
md = osxmetadata.OSXMetaData(f)
if sorted(md.tags) != sorted(tags):
verbose_(f"Writing Finder tags to {f}")
md.tags = tags
written.append(f)
else:
verbose_(f"Skipping Finder tags for {f}: nothing to do")
skipped.append(f)
return (written, skipped)
if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter

View File

@ -1,5 +1,5 @@
""" version info """
__version__ = "0.38.22"
__version__ = "0.39.0"

View File

@ -74,6 +74,8 @@ class ExportResults:
error=None,
exiftool_warning=None,
exiftool_error=None,
xattr_written=None,
xattr_skipped=None,
):
self.exported = exported or []
self.new = new or []
@ -92,6 +94,8 @@ class ExportResults:
self.error = error or []
self.exiftool_warning = exiftool_warning or []
self.exiftool_error = exiftool_error or []
self.xattr_written = xattr_written or []
self.xattr_skipped = xattr_skipped or []
def all_files(self):
""" return all filenames contained in results """

View File

@ -81,6 +81,7 @@ setup(
"wurlitzer>=2.0.1",
"photoscript>=0.1.0",
"toml>=0.10.0",
"osxmetadata>=0.99.11",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,