Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b06b9fdb | ||
|
|
29e424575a | ||
|
|
ea373c4197 | ||
|
|
f25a299309 | ||
|
|
5885b23d32 | ||
|
|
5dccdf7750 | ||
|
|
e9134f84df | ||
|
|
3872e7ae64 | ||
|
|
b3e86dffc8 | ||
|
|
4897fc4b05 | ||
|
|
1dbf22fdc9 | ||
|
|
fa58af8b88 | ||
|
|
9c9bcb08b3 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v0.38.21](https://github.com/RhetTbull/osxphotos/compare/v0.38.20...v0.38.21)
|
||||
|
||||
> 29 December 2020
|
||||
|
||||
- Fixed --exiftool-path to work with --exiftool-merge-keywords/persons [`3872e7a`](https://github.com/RhetTbull/osxphotos/commit/3872e7ae649f42d849de472a7dbf78a241d54407)
|
||||
|
||||
#### [v0.38.20](https://github.com/RhetTbull/osxphotos/compare/v0.38.19...v0.38.20)
|
||||
|
||||
> 29 December 2020
|
||||
|
||||
- Added --exiftool-path to CLI [`4897fc4`](https://github.com/RhetTbull/osxphotos/commit/4897fc4b05cc7a3bea314f9cce8a2163bf3922b2)
|
||||
|
||||
#### [v0.38.19](https://github.com/RhetTbull/osxphotos/compare/v0.38.18...v0.38.19)
|
||||
|
||||
> 29 December 2020
|
||||
|
||||
- Added exiftool signature to JSON output, issue #303 [`fa58af8`](https://github.com/RhetTbull/osxphotos/commit/fa58af8b883da11fdfa723d2da75a600d927d46e)
|
||||
|
||||
#### [v0.38.18](https://github.com/RhetTbull/osxphotos/compare/v0.38.17...v0.38.18)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
- Added --exiftool-merge-keywords/persons, issue #299, #292 [`b1cb99f`](https://github.com/RhetTbull/osxphotos/commit/b1cb99f83f55128a314d265d4588134cb79026c6)
|
||||
|
||||
#### [v0.38.17](https://github.com/RhetTbull/osxphotos/compare/v0.38.16...v0.38.17)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
34
README.md
34
README.md
@@ -363,6 +363,8 @@ Options:
|
||||
(see also --ignore-date-modified);
|
||||
QuickTime:GPSCoordinates;
|
||||
UserData:GPSCoordinates.
|
||||
--exiftool-path EXIFTOOL_PATH Optionally specify path to exiftool; if not
|
||||
provided, will look for exiftool in $PATH.
|
||||
--exiftool-option OPTION Optional flag/option to pass to exiftool
|
||||
when using --exiftool. For example,
|
||||
--exiftool-option '-m' to ignore minor
|
||||
@@ -410,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
|
||||
@@ -512,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
|
||||
|
||||
@@ -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 **")
|
||||
@@ -1157,8 +1177,9 @@ def query(
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
|
||||
photos = _query(
|
||||
db=db,
|
||||
photosdb=photosdb,
|
||||
keyword=keyword,
|
||||
person=person,
|
||||
album=album,
|
||||
@@ -1383,6 +1404,12 @@ def query(
|
||||
"(video files only): QuickTime:CreationDate; QuickTime:CreateDate; QuickTime:ModifyDate (see also --ignore-date-modified); "
|
||||
"QuickTime:GPSCoordinates; UserData:GPSCoordinates.",
|
||||
)
|
||||
@click.option(
|
||||
"--exiftool-path",
|
||||
metavar="EXIFTOOL_PATH",
|
||||
type=click.Path(exists=True),
|
||||
help="Optionally specify path to exiftool; if not provided, will look for exiftool in $PATH.",
|
||||
)
|
||||
@click.option(
|
||||
"--exiftool-option",
|
||||
multiple=True,
|
||||
@@ -1447,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",
|
||||
@@ -1587,6 +1630,8 @@ def export(
|
||||
album_keyword,
|
||||
keyword_template,
|
||||
description_template,
|
||||
finder_tag_template,
|
||||
finder_tag_keywords,
|
||||
current_name,
|
||||
convert_to_jpeg,
|
||||
jpeg_quality,
|
||||
@@ -1601,6 +1646,7 @@ def export(
|
||||
download_missing,
|
||||
dest,
|
||||
exiftool,
|
||||
exiftool_path,
|
||||
exiftool_option,
|
||||
exiftool_merge_keywords,
|
||||
exiftool_merge_persons,
|
||||
@@ -1678,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
|
||||
@@ -1722,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
|
||||
@@ -1735,6 +1783,7 @@ def export(
|
||||
not_live = cfg.not_live
|
||||
download_missing = cfg.download_missing
|
||||
exiftool = cfg.exiftool
|
||||
exiftool_path = cfg.exiftool_path
|
||||
exiftool_option = cfg.exiftool_option
|
||||
exiftool_merge_keywords = cfg.exiftool_merge_keywords
|
||||
exiftool_merge_persons = cfg.exiftool_merge_persons
|
||||
@@ -1881,10 +1930,15 @@ def export(
|
||||
not x for x in [skip_edited, skip_bursts, skip_live, skip_raw]
|
||||
]
|
||||
|
||||
# verify exiftool installed an in path
|
||||
if exiftool:
|
||||
# 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:
|
||||
_ = get_exiftool_path()
|
||||
exiftool_path = get_exiftool_path()
|
||||
except FileNotFoundError:
|
||||
click.echo(
|
||||
click.style(
|
||||
@@ -1896,6 +1950,9 @@ def export(
|
||||
)
|
||||
ctx.exit(2)
|
||||
|
||||
if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]):
|
||||
verbose_(f"exiftool path: {exiftool_path}")
|
||||
|
||||
isphoto = ismovie = True # default searches for everything
|
||||
if only_movies:
|
||||
isphoto = False
|
||||
@@ -1977,8 +2034,9 @@ def export(
|
||||
f"Upgraded export database {export_db_path} from version {upgraded[0]} to {upgraded[1]}"
|
||||
)
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_, exiftool=exiftool_path)
|
||||
photos = _query(
|
||||
db=db,
|
||||
photosdb=photosdb,
|
||||
keyword=keyword,
|
||||
person=person,
|
||||
album=album,
|
||||
@@ -2057,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,
|
||||
@@ -2100,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 = (
|
||||
@@ -2327,7 +2361,7 @@ def print_photo_info(photos, json=False):
|
||||
|
||||
|
||||
def _query(
|
||||
db=None,
|
||||
photosdb,
|
||||
keyword=None,
|
||||
person=None,
|
||||
album=None,
|
||||
@@ -2386,12 +2420,12 @@ def _query(
|
||||
has_likes=False,
|
||||
no_likes=False,
|
||||
):
|
||||
"""run a query against PhotosDB to extract the photos based on user supply criteria
|
||||
used by query and export commands
|
||||
arguments must be passed in same order as query and export
|
||||
if either is modified, need to ensure all three functions are updated"""
|
||||
""" Run a query against PhotosDB to extract the photos based on user supply criteria used by query and export commands
|
||||
|
||||
Args:
|
||||
photosdb: PhotosDB object
|
||||
"""
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
|
||||
if deleted or deleted_only:
|
||||
photos = photosdb.photos(
|
||||
uuid=uuid,
|
||||
@@ -3193,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()
|
||||
}
|
||||
@@ -3254,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",
|
||||
@@ -3270,6 +3312,8 @@ def write_export_report(report_file, results):
|
||||
"error",
|
||||
"exiftool_warning",
|
||||
"exiftool_error",
|
||||
"extended_attributes_written",
|
||||
"extended_attributes_skipped",
|
||||
]
|
||||
|
||||
try:
|
||||
@@ -3321,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.38.18"
|
||||
__version__ = "0.39.0"
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class _ExifToolProc:
|
||||
|
||||
if hasattr(self, "_process_running") and self._process_running:
|
||||
# already running
|
||||
if exiftool is not None:
|
||||
if exiftool is not None and exiftool != self._exiftool:
|
||||
logging.warning(
|
||||
f"exiftool subprocess already running, "
|
||||
f"ignoring exiftool={exiftool}"
|
||||
@@ -266,7 +266,7 @@ class ExifTool:
|
||||
+ b"\n"
|
||||
+ b"-execute\n"
|
||||
)
|
||||
|
||||
|
||||
# send the command
|
||||
self._process.stdin.write(command_str)
|
||||
self._process.stdin.flush()
|
||||
|
||||
@@ -18,12 +18,11 @@ def exiftool(self):
|
||||
return self._exiftool
|
||||
except AttributeError:
|
||||
try:
|
||||
exiftool_path = get_exiftool_path()
|
||||
exiftool_path = self._db._exiftool_path or get_exiftool_path()
|
||||
if self.path is not None and os.path.isfile(self.path):
|
||||
exiftool = ExifTool(self.path)
|
||||
exiftool = ExifTool(self.path, exiftool=exiftool_path)
|
||||
else:
|
||||
exiftool = None
|
||||
logging.debug(f"exiftool: missing path {self.uuid}")
|
||||
except FileNotFoundError:
|
||||
# get_exiftool_path raises FileNotFoundError if exiftool not found
|
||||
exiftool = None
|
||||
|
||||
@@ -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 """
|
||||
@@ -912,6 +916,7 @@ def export2(
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
filename=dest.name,
|
||||
)
|
||||
sidecars.append(
|
||||
(
|
||||
@@ -934,6 +939,7 @@ def export2(
|
||||
tag_groups=False,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
filename=dest.name,
|
||||
)
|
||||
sidecars.append(
|
||||
(
|
||||
@@ -1356,7 +1362,7 @@ def _write_exif_data(
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
)
|
||||
|
||||
with ExifTool(filepath, flags=flags) as exiftool:
|
||||
with ExifTool(filepath, flags=flags, exiftool=self._db._exiftool_path) as exiftool:
|
||||
for exiftag, val in exif_info.items():
|
||||
if type(val) == list:
|
||||
for v in val:
|
||||
@@ -1375,11 +1381,13 @@ def _exiftool_dict(
|
||||
ignore_date_modified=False,
|
||||
merge_exif_keywords=False,
|
||||
merge_exif_persons=False,
|
||||
filename=None,
|
||||
):
|
||||
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||
Does not include all the EXIF fields as those are likely already in the image.
|
||||
|
||||
Args:
|
||||
filename: name of source image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included
|
||||
use_albums_as_keywords: treat album names as keywords
|
||||
use_persons_as_keywords: treat person names as keywords
|
||||
keyword_template: (list of strings); list of template strings to render as keywords
|
||||
@@ -1413,7 +1421,16 @@ def _exiftool_dict(
|
||||
UserData:GPSCoordinates
|
||||
"""
|
||||
|
||||
exif = {}
|
||||
exif = (
|
||||
{
|
||||
"SourceFile": filename,
|
||||
"ExifTool:ExifToolVersion": "12.00",
|
||||
"File:FileName": filename,
|
||||
}
|
||||
if filename is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
if description_template is not None:
|
||||
rendered = self.render_template(
|
||||
description_template, expand_inplace=True, inplace_sep=", "
|
||||
@@ -1611,6 +1628,7 @@ def _exiftool_json_sidecar(
|
||||
tag_groups=True,
|
||||
merge_exif_keywords=False,
|
||||
merge_exif_persons=False,
|
||||
filename=None,
|
||||
):
|
||||
"""Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
|
||||
Does not include all the EXIF fields as those are likely already in the image.
|
||||
@@ -1624,6 +1642,7 @@ def _exiftool_json_sidecar(
|
||||
tag_groups: if True, tags are in form Group:TagName, e.g. IPTC:Keywords, otherwise group name is omitted, e.g. Keywords
|
||||
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
|
||||
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
|
||||
filename: filename of the destination image file for including in exiftool signature in JSON sidecar
|
||||
|
||||
Returns: dict with exiftool tags / values
|
||||
|
||||
@@ -1657,6 +1676,7 @@ def _exiftool_json_sidecar(
|
||||
ignore_date_modified=ignore_date_modified,
|
||||
merge_exif_keywords=merge_exif_keywords,
|
||||
merge_exif_persons=merge_exif_persons,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
if not tag_groups:
|
||||
|
||||
@@ -70,12 +70,13 @@ class PhotosDB:
|
||||
from ._photosdb_process_scoreinfo import _process_scoreinfo
|
||||
from ._photosdb_process_comments import _process_comments
|
||||
|
||||
def __init__(self, dbfile=None, verbose=None):
|
||||
def __init__(self, dbfile=None, verbose=None, exiftool=None):
|
||||
""" Create a new PhotosDB object.
|
||||
|
||||
Args:
|
||||
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
|
||||
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if dbfile is not a valid Photos library.
|
||||
@@ -98,6 +99,8 @@ class PhotosDB:
|
||||
raise TypeError("verbose must be callable")
|
||||
self._verbose = verbose
|
||||
|
||||
self._exiftool_path = exiftool
|
||||
|
||||
# create a temporary directory
|
||||
# tempfile.TemporaryDirectory gets cleaned up when the object does
|
||||
self._tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
|
||||
@@ -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
|
||||
|
||||
1
setup.py
1
setup.py
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -1055,6 +1068,52 @@ def test_export_exiftool():
|
||||
assert exif[key] == CLI_EXIFTOOL[uuid][key]
|
||||
|
||||
|
||||
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||
def test_export_exiftool_path():
|
||||
""" test --exiftool with --exiftool-path """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import shutil
|
||||
import tempfile
|
||||
from osxphotos.__main__ import export
|
||||
from osxphotos.exiftool import ExifTool, get_exiftool_path
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
tempdir = tempfile.TemporaryDirectory()
|
||||
exiftool_source = get_exiftool_path()
|
||||
exiftool_path = os.path.join(tempdir.name, "myexiftool")
|
||||
shutil.copy2(exiftool_source, exiftool_path)
|
||||
for uuid in CLI_EXIFTOOL:
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_6),
|
||||
".",
|
||||
"-V",
|
||||
"--exiftool",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
"--exiftool-path",
|
||||
exiftool_path,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert f"exiftool path: {exiftool_path}" in result.output
|
||||
files = glob.glob("*")
|
||||
assert sorted(files) == sorted([CLI_EXIFTOOL[uuid]["File:FileName"]])
|
||||
|
||||
exif = ExifTool(CLI_EXIFTOOL[uuid]["File:FileName"]).asdict()
|
||||
for key in CLI_EXIFTOOL[uuid]:
|
||||
if type(exif[key]) == list:
|
||||
assert sorted(exif[key]) == sorted(CLI_EXIFTOOL[uuid][key])
|
||||
else:
|
||||
assert exif[key] == CLI_EXIFTOOL[uuid][key]
|
||||
|
||||
|
||||
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||
def test_export_exiftool_ignore_date_modified():
|
||||
import glob
|
||||
@@ -3382,9 +3441,12 @@ def test_export_sidecar_keyword_template():
|
||||
|
||||
json_expected = json.loads(
|
||||
"""
|
||||
[{"EXIF:ImageDescription": "Girl holding pumpkin",
|
||||
[{"SourceFile": "Pumkins2.jpg",
|
||||
"ExifTool:ExifToolVersion": "12.00",
|
||||
"File:FileName": "Pumkins2.jpg",
|
||||
"EXIF:ImageDescription": "Girl holding pumpkin",
|
||||
"XMP:Description": "Girl holding pumpkin",
|
||||
"XMP:Title": "I found one!",
|
||||
"XMP:Title": "I found one!",
|
||||
"XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
|
||||
"IPTC:Keywords": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"],
|
||||
"XMP:PersonInImage": ["Katie"],
|
||||
@@ -4765,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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user