Initial implementation for Finder tags
This commit is contained in:
@@ -13,12 +13,14 @@ import unicodedata
|
|||||||
import click
|
import click
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
import osxmetadata
|
||||||
import osxphotos
|
import osxphotos
|
||||||
|
|
||||||
from ._constants import (
|
from ._constants import (
|
||||||
_EXIF_TOOL_URL,
|
_EXIF_TOOL_URL,
|
||||||
_PHOTOS_4_VERSION,
|
_PHOTOS_4_VERSION,
|
||||||
_UNKNOWN_PLACE,
|
_UNKNOWN_PLACE,
|
||||||
|
_OSXPHOTOS_NONE_SENTINEL,
|
||||||
CLI_COLOR_ERROR,
|
CLI_COLOR_ERROR,
|
||||||
CLI_COLOR_WARNING,
|
CLI_COLOR_WARNING,
|
||||||
DEFAULT_EDITED_SUFFIX,
|
DEFAULT_EDITED_SUFFIX,
|
||||||
@@ -1454,6 +1456,22 @@ def query(
|
|||||||
'--description-template "{descr} exported with osxphotos on {today.date}" '
|
'--description-template "{descr} exported with osxphotos on {today.date}" '
|
||||||
"See Templating System below.",
|
"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(
|
@click.option(
|
||||||
"--directory",
|
"--directory",
|
||||||
metavar="DIRECTORY",
|
metavar="DIRECTORY",
|
||||||
@@ -1594,6 +1612,8 @@ def export(
|
|||||||
album_keyword,
|
album_keyword,
|
||||||
keyword_template,
|
keyword_template,
|
||||||
description_template,
|
description_template,
|
||||||
|
finder_tag_template,
|
||||||
|
finder_tag_keywords,
|
||||||
current_name,
|
current_name,
|
||||||
convert_to_jpeg,
|
convert_to_jpeg,
|
||||||
jpeg_quality,
|
jpeg_quality,
|
||||||
@@ -1730,6 +1750,8 @@ def export(
|
|||||||
album_keyword = cfg.album_keyword
|
album_keyword = cfg.album_keyword
|
||||||
keyword_template = cfg.keyword_template
|
keyword_template = cfg.keyword_template
|
||||||
description_template = cfg.description_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
|
current_name = cfg.current_name
|
||||||
convert_to_jpeg = cfg.convert_to_jpeg
|
convert_to_jpeg = cfg.convert_to_jpeg
|
||||||
jpeg_quality = cfg.jpeg_quality
|
jpeg_quality = cfg.jpeg_quality
|
||||||
@@ -1890,8 +1912,13 @@ def export(
|
|||||||
not x for x in [skip_edited, skip_bursts, skip_live, skip_raw]
|
not x for x in [skip_edited, skip_bursts, skip_live, skip_raw]
|
||||||
]
|
]
|
||||||
|
|
||||||
# verify exiftool installed and in path if path not provided
|
# verify exiftool installed and in path if path not provided and exiftool will be used
|
||||||
if (exiftool or exiftool_merge_keywords or exiftool_merge_persons) and not exiftool_path:
|
# 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:
|
try:
|
||||||
exiftool_path = get_exiftool_path()
|
exiftool_path = get_exiftool_path()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -1905,7 +1932,7 @@ def export(
|
|||||||
)
|
)
|
||||||
ctx.exit(2)
|
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}")
|
verbose_(f"exiftool path: {exiftool_path}")
|
||||||
|
|
||||||
isphoto = ismovie = True # default searches for everything
|
isphoto = ismovie = True # default searches for everything
|
||||||
@@ -2070,8 +2097,10 @@ def export(
|
|||||||
original_name = not current_name
|
original_name = not current_name
|
||||||
|
|
||||||
results = ExportResults()
|
results = ExportResults()
|
||||||
if verbose:
|
# send progress bar output to /dev/null if verbose to hide the progress bar
|
||||||
for p in photos:
|
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(
|
export_results = export_photo(
|
||||||
photo=p,
|
photo=p,
|
||||||
dest=dest,
|
dest=dest,
|
||||||
@@ -2113,56 +2142,30 @@ def export(
|
|||||||
)
|
)
|
||||||
results += export_results
|
results += export_results
|
||||||
|
|
||||||
# if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg":
|
if finder_tag_keywords or finder_tag_template:
|
||||||
# for photo_file in set(
|
files = set(
|
||||||
# results.exported + results.updated + results.exif_updated
|
export_results.exported
|
||||||
# ):
|
+ export_results.new
|
||||||
# verbose_(f"Converting {photo_file} to jpeg")
|
+ export_results.updated
|
||||||
|
+ export_results.exif_updated
|
||||||
else:
|
+ export_results.converted_to_jpeg
|
||||||
# show progress bar
|
+ export_results.skipped
|
||||||
with click.progressbar(photos) as bar:
|
)
|
||||||
for p in bar:
|
tags_written, tags_skipped = write_finder_tags(
|
||||||
export_results = export_photo(
|
p,
|
||||||
photo=p,
|
files,
|
||||||
dest=dest,
|
keywords=finder_tag_keywords,
|
||||||
verbose=verbose,
|
keyword_template=keyword_template,
|
||||||
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,
|
|
||||||
album_keyword=album_keyword,
|
album_keyword=album_keyword,
|
||||||
person_keyword=person_keyword,
|
person_keyword=person_keyword,
|
||||||
keyword_template=keyword_template,
|
exiftool_merge_keywords=exiftool_merge_keywords,
|
||||||
description_template=description_template,
|
finder_tag_template=finder_tag_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,
|
|
||||||
)
|
)
|
||||||
results += export_results
|
results.xattr_written.extend(tags_written)
|
||||||
|
results.xattr_skipped.extend(tags_skipped)
|
||||||
|
|
||||||
|
if fp is not None:
|
||||||
|
fp.close()
|
||||||
|
|
||||||
if cleanup:
|
if cleanup:
|
||||||
all_files = (
|
all_files = (
|
||||||
@@ -3206,6 +3209,8 @@ def write_export_report(report_file, results):
|
|||||||
"error": 0,
|
"error": 0,
|
||||||
"exiftool_warning": "",
|
"exiftool_warning": "",
|
||||||
"exiftool_error": "",
|
"exiftool_error": "",
|
||||||
|
"extended_attributes_written": 0,
|
||||||
|
"extended_attributes_skipped": 0,
|
||||||
}
|
}
|
||||||
for result in results.all_files()
|
for result in results.all_files()
|
||||||
}
|
}
|
||||||
@@ -3267,6 +3272,12 @@ def write_export_report(report_file, results):
|
|||||||
for result in results.exiftool_error:
|
for result in results.exiftool_error:
|
||||||
all_results[result[0]]["exiftool_error"] = result[1]
|
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 = [
|
report_columns = [
|
||||||
"filename",
|
"filename",
|
||||||
"exported",
|
"exported",
|
||||||
@@ -3283,6 +3294,8 @@ def write_export_report(report_file, results):
|
|||||||
"error",
|
"error",
|
||||||
"exiftool_warning",
|
"exiftool_warning",
|
||||||
"exiftool_error",
|
"exiftool_error",
|
||||||
|
"extended_attributes_written",
|
||||||
|
"extended_attributes_skipped",
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3334,5 +3347,84 @@ def cleanup_files(dest_path, files_to_keep, fileutil):
|
|||||||
return (deleted_files, deleted_dirs)
|
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__":
|
if __name__ == "__main__":
|
||||||
cli() # pylint: disable=no-value-for-parameter
|
cli() # pylint: disable=no-value-for-parameter
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.38.22"
|
__version__ = "0.39.0"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ class ExportResults:
|
|||||||
error=None,
|
error=None,
|
||||||
exiftool_warning=None,
|
exiftool_warning=None,
|
||||||
exiftool_error=None,
|
exiftool_error=None,
|
||||||
|
xattr_written=None,
|
||||||
|
xattr_skipped=None,
|
||||||
):
|
):
|
||||||
self.exported = exported or []
|
self.exported = exported or []
|
||||||
self.new = new or []
|
self.new = new or []
|
||||||
@@ -92,6 +94,8 @@ class ExportResults:
|
|||||||
self.error = error or []
|
self.error = error or []
|
||||||
self.exiftool_warning = exiftool_warning or []
|
self.exiftool_warning = exiftool_warning or []
|
||||||
self.exiftool_error = exiftool_error or []
|
self.exiftool_error = exiftool_error or []
|
||||||
|
self.xattr_written = xattr_written or []
|
||||||
|
self.xattr_skipped = xattr_skipped or []
|
||||||
|
|
||||||
def all_files(self):
|
def all_files(self):
|
||||||
""" return all filenames contained in results """
|
""" return all filenames contained in results """
|
||||||
|
|||||||
1
setup.py
1
setup.py
@@ -81,6 +81,7 @@ setup(
|
|||||||
"wurlitzer>=2.0.1",
|
"wurlitzer>=2.0.1",
|
||||||
"photoscript>=0.1.0",
|
"photoscript>=0.1.0",
|
||||||
"toml>=0.10.0",
|
"toml>=0.10.0",
|
||||||
|
"osxmetadata>=0.99.11",
|
||||||
],
|
],
|
||||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
|
|||||||
Reference in New Issue
Block a user