Merge pull request #310 from RhetTbull/finder_tags

Added Finder tags, partial implementation for issue #242
This commit is contained in:
Rhet Turnbull
2020-12-30 13:52:37 -08:00
committed by GitHub
7 changed files with 455 additions and 54 deletions

View File

@@ -412,6 +412,20 @@ Options:
could specify --description-template
"{descr} exported with osxphotos on
{today.date}" See Templating System below.
--finder-tag-template TEMPLATE 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 and Extended Attributes
below.'.
--finder-tag-keywords 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
and Extended Attributes below.'.
--directory DIRECTORY Optional template for specifying name of
output directory in the form
'{name,DEFAULT}'. See below for additional
@@ -514,6 +528,24 @@ option to re-export the entire library thus rebuilding the
'.osxphotos_export.db' database.
** Extended Attributes **
Some options (currently '--finder-tag-template' and '--finder-tag-keywords')
write additional metadata to extended attributes in the file. These options
will only work if the destination filesystem supports extended attributes
(most do). For example, --finder-tag-keyword writes all keywords (including
any specified by '--keyword-template' or other options) to Finder tags that
are searchable in Spotlight using the syntax: 'tag:tagname'. For example, if
you have images with keyword "Travel" then using '--finder-tag-keywords' you
could quickly find those images in the Finder by typing 'tag:Travel' in the
Spotlight search bar. Finder tags are written to the
'com.apple.metadata:_kMDItemUserTags' extended attribute. Unlike EXIF
metadata, extended attributes do not modify the actual file. Most cloud
storage services do not synch extended attributes. Dropbox does sync them and
any changes to a file's extended attributes will cause Dropbox to re-sync the
files.
** Templating System **
Several options, such as --directory, allow you to specify a template which

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,
@@ -179,6 +181,24 @@ class ExportCommand(click.Command):
+ "You can always run export without the --update option to re-export the entire library thus "
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
)
formatter.write("\n\n")
formatter.write_text("** Extended Attributes **")
formatter.write("\n")
formatter.write_text(
"""
Some options (currently '--finder-tag-template' and '--finder-tag-keywords') write
additional metadata to extended attributes in the file. These options will only work
if the destination filesystem supports extended attributes (most do).
For example, --finder-tag-keyword writes all keywords (including any specified by '--keyword-template'
or other options) to Finder tags that are searchable in Spotlight using the syntax: 'tag:tagname'.
For example, if you have images with keyword "Travel" then using '--finder-tag-keywords' you could quickly
find those images in the Finder by typing 'tag:Travel' in the Spotlight search bar.
Finder tags are written to the 'com.apple.metadata:_kMDItemUserTags' extended attribute.
Unlike EXIF metadata, extended attributes do not modify the actual file. Most cloud storage services
do not synch extended attributes. Dropbox does sync them and any changes to a file's extended attributes
will cause Dropbox to re-sync the files.
"""
)
formatter.write("\n\n")
formatter.write_text("** Templating System **")
@@ -1454,6 +1474,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 MacOS 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 and Extended Attributes below.'.",
)
@click.option(
"--finder-tag-keywords",
is_flag=True,
help="Set MacOS 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 and Extended Attributes below.'.",
)
@click.option(
"--directory",
metavar="DIRECTORY",
@@ -1594,6 +1630,8 @@ def export(
album_keyword,
keyword_template,
description_template,
finder_tag_template,
finder_tag_keywords,
current_name,
convert_to_jpeg,
jpeg_quality,
@@ -1686,7 +1724,7 @@ def export(
)
raise click.Abort()
# re-set the local function vars to the corresponding config value
# re-set the local vars to the corresponding config value
# this isn't elegant but avoids having to rewrite this function to use cfg.varname for every parameter
db = cfg.db
photos_library = cfg.photos_library
@@ -1730,6 +1768,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 +1930,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 +1950,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 +2115,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 +2160,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 +3227,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 +3290,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 +3312,8 @@ def write_export_report(report_file, results):
"error",
"exiftool_warning",
"exiftool_error",
"extended_attributes_written",
"extended_attributes_skipped",
]
try:
@@ -3334,5 +3365,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

@@ -42,6 +42,7 @@ mccabe==0.6.1
modulegraph==0.18
more-itertools==7.2.0
multidict==4.7.6
osxmetadata>=0.99.11
packaging==19.0
parso==0.6.2
pathspec==0.7.0

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,

View File

@@ -439,6 +439,19 @@ CLI_EXIFTOOL_DUPLICATE_KEYWORDS = {
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": "wedding.jpg"
}
CLI_FINDER_TAGS = {
"D79B8D77-BFFC-460B-9312-034F2877D35B": {
"File:FileName": "Pumkins2.jpg",
"IPTC:Keywords": "Kids",
"XMP:TagsList": "Kids",
"XMP:Title": "I found one!",
"EXIF:ImageDescription": "Girl holding pumpkin",
"XMP:Description": "Girl holding pumpkin",
"XMP:PersonInImage": "Katie",
"XMP:Subject": "Kids",
}
}
LABELS_JSON = {
"labels": {
"Plant": 7,
@@ -4814,3 +4827,243 @@ def test_export_exportdb():
"Error: --exportdb must be specified as filename not path" in result.output
)
def test_export_finder_tag_keywords():
""" test --finder-tag-keywords """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_FINDER_TAGS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-keywords",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
# run again with --update, should skip writing extended attributes
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-keywords",
"--uuid",
f"{uuid}",
"--update",
],
)
assert result.exit_code == 0
assert "Skipping Finder tags" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
# clear tags and run again, should update extended attributes
md.tags = None
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-keywords",
"--uuid",
f"{uuid}",
"--update",
],
)
assert result.exit_code == 0
assert "Writing Finder tags" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
def test_export_finder_tag_template():
""" test --finder-tag-template """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_FINDER_TAGS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
# run again with --update, should skip writing extended attributes
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
"--update",
],
)
assert result.exit_code == 0
assert "Skipping Finder tags" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
# clear tags and run again, should update extended attributes
md.tags = None
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
"--update",
],
)
assert result.exit_code == 0
assert "Writing Finder tags" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords]
assert sorted(md.tags) == sorted(expected)
def test_export_finder_tag_template_multiple():
""" test --finder-tag-template used more than once """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_FINDER_TAGS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-template",
"{keyword}",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
persons = [persons] if type(persons) != list else persons
expected = [Tag(x) for x in keywords + persons]
assert sorted(md.tags) == sorted(expected)
def test_export_finder_tag_template_keywords():
""" test --finder-tag-template with --finder-tag-keywords """
import glob
import os
import os.path
from osxmetadata import OSXMetaData, Tag
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_FINDER_TAGS:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--finder-tag-keywords",
"--finder-tag-template",
"{person}",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
persons = [persons] if type(persons) != list else persons
expected = [Tag(x) for x in keywords + persons]
assert sorted(md.tags) == sorted(expected)