Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f57abd23c | ||
|
|
f9a43b92c1 | ||
|
|
bf2a55d7f6 | ||
|
|
34bb7f2cdc | ||
|
|
3394c52768 | ||
|
|
27282af3b9 | ||
|
|
b7b06b9fdb | ||
|
|
29e424575a | ||
|
|
ea373c4197 | ||
|
|
f25a299309 | ||
|
|
5885b23d32 | ||
|
|
5dccdf7750 | ||
|
|
e9134f84df | ||
|
|
3872e7ae64 | ||
|
|
b3e86dffc8 | ||
|
|
4897fc4b05 | ||
|
|
1dbf22fdc9 | ||
|
|
fa58af8b88 | ||
|
|
9c9bcb08b3 |
52
CHANGELOG.md
52
CHANGELOG.md
@@ -4,6 +4,58 @@ 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.39.2](https://github.com/RhetTbull/osxphotos/compare/v0.39.1...v0.39.2)
|
||||
|
||||
> 31 December 2020
|
||||
|
||||
- Added --xattr-template, closes #242 [`#242`](https://github.com/RhetTbull/osxphotos/issues/242)
|
||||
|
||||
#### [v0.39.1](https://github.com/RhetTbull/osxphotos/compare/v0.39.0...v0.39.1)
|
||||
|
||||
> 31 December 2020
|
||||
|
||||
- Fixed --exiftool-path bug, issue #311, #313 [`3394c52`](https://github.com/RhetTbull/osxphotos/commit/3394c527682d8fdd2f20f4f778d802dab86b6372)
|
||||
|
||||
#### [v0.39.0](https://github.com/RhetTbull/osxphotos/compare/v0.38.22...v0.39.0)
|
||||
|
||||
> 30 December 2020
|
||||
|
||||
- Added Finder tags, partial implementation for issue #242 [`#310`](https://github.com/RhetTbull/osxphotos/pull/310)
|
||||
- Added tests for Finder tags [`29e4245`](https://github.com/RhetTbull/osxphotos/commit/29e424575a522ae03efe5a140be46bfd0a1346c5)
|
||||
- Initial implementation for Finder tags [`5885b23`](https://github.com/RhetTbull/osxphotos/commit/5885b23d3249cf91953092a6b1ce967da2667e29)
|
||||
- Updated README for finder tags [`f25a299`](https://github.com/RhetTbull/osxphotos/commit/f25a2993097ad7b2b8ab2d1c787db58c0d799a41)
|
||||
- Updated requirements.txt [`ea373c4`](https://github.com/RhetTbull/osxphotos/commit/ea373c4197ce1cce00e89157fe560d1366f7e764)
|
||||
|
||||
#### [v0.38.22](https://github.com/RhetTbull/osxphotos/compare/v0.38.21...v0.38.22)
|
||||
|
||||
> 30 December 2020
|
||||
|
||||
- Fixed --exiftool-path bug, issue #308 [`5dccdf7`](https://github.com/RhetTbull/osxphotos/commit/5dccdf7750611c78de5356bb02f6023d4fc382c5)
|
||||
|
||||
#### [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
|
||||
|
||||
133
README.md
133
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,30 @@ Options:
|
||||
could specify --description-template
|
||||
"{descr} exported with osxphotos on
|
||||
{today.date}" See Templating System below.
|
||||
--finder-tag-template TEMPLATE 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.'.
|
||||
--finder-tag-keywords 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.'.
|
||||
--xattr-template ATTRIBUTE TEMPLATE
|
||||
Set extended attribute ATTRIBUTE to TEMPLATE
|
||||
value. Valid attributes are: 'authors',
|
||||
'comment', 'copyright', 'description',
|
||||
'findercomment', 'headline', 'keywords'. For
|
||||
example, to set Finder comment to the
|
||||
photo's title and description: '--xattr-
|
||||
template findercomment "{title}; {descr}"
|
||||
See Extended Attributes below for additional
|
||||
details on this option.
|
||||
--directory DIRECTORY Optional template for specifying name of
|
||||
output directory in the form
|
||||
'{name,DEFAULT}'. See below for additional
|
||||
@@ -512,6 +538,53 @@ option to re-export the entire library thus rebuilding the
|
||||
'.osxphotos_export.db' database.
|
||||
|
||||
|
||||
** Extended Attributes **
|
||||
|
||||
Some options (currently '--finder-tag-template', '--finder-tag-keywords',
|
||||
'-xattr-template') 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.
|
||||
|
||||
The following attributes may be used with '--xattr-template':
|
||||
|
||||
|
||||
authors The author, or authors, of the contents of the file. A list
|
||||
of strings. (com.apple.metadata:kMDItemAuthors)
|
||||
comment A comment related to the file. This differs from the Finder
|
||||
comment, kMDItemFinderComment. A string.
|
||||
(com.apple.metadata:kMDItemComment)
|
||||
copyright The copyright owner of the file contents. A string.
|
||||
(com.apple.metadata:kMDItemCopyright)
|
||||
description A description of the content of the resource. The
|
||||
description may include an abstract, table of contents,
|
||||
reference to a graphical representation of content or a free-
|
||||
text account of the content. A string.
|
||||
(com.apple.metadata:kMDItemDescription)
|
||||
findercomment Finder comments for this file. A string.
|
||||
(com.apple.metadata:kMDItemFinderComment)
|
||||
headline A publishable entry providing a synopsis of the contents of
|
||||
the file. A string. (com.apple.metadata:kMDItemHeadline)
|
||||
keywords Keywords associated with this file. For example, “Birthday”,
|
||||
“Important”, etc. This differs from Finder tags
|
||||
(_kMDItemUserTags) which are keywords/tags shown in the
|
||||
Finder and searchable in Spotlight using "tag:tag_name". A
|
||||
list of strings. (com.apple.metadata:kMDItemKeywords)
|
||||
|
||||
For additional information on extended attributes see: https://developer.apple
|
||||
.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute
|
||||
_keys
|
||||
|
||||
|
||||
** Templating System **
|
||||
|
||||
Several options, such as --directory, allow you to specify a template which
|
||||
@@ -667,27 +740,39 @@ Substitution Description
|
||||
https://strftime.org/ for help on strftime
|
||||
templates.
|
||||
{modified.date} Photo's modification date in ISO format,
|
||||
e.g. '2020-03-22'
|
||||
{modified.year} 4-digit year of photo modification time
|
||||
{modified.yy} 2-digit year of photo modification time
|
||||
e.g. '2020-03-22'; uses creation date if
|
||||
photo is not modified
|
||||
{modified.year} 4-digit year of photo modification time;
|
||||
uses creation date if photo is not modified
|
||||
{modified.yy} 2-digit year of photo modification time;
|
||||
uses creation date if photo is not modified
|
||||
{modified.mm} 2-digit month of the photo modification time
|
||||
(zero padded)
|
||||
(zero padded); uses creation date if photo
|
||||
is not modified
|
||||
{modified.month} Month name in user's locale of the photo
|
||||
modification time
|
||||
modification time; uses creation date if
|
||||
photo is not modified
|
||||
{modified.mon} Month abbreviation in the user's locale of
|
||||
the photo modification time
|
||||
the photo modification time; uses creation
|
||||
date if photo is not modified
|
||||
{modified.dd} 2-digit day of the month (zero padded) of
|
||||
the photo modification time
|
||||
the photo modification time; uses creation
|
||||
date if photo is not modified
|
||||
{modified.dow} Day of week in user's locale of the photo
|
||||
modification time
|
||||
modification time; uses creation date if
|
||||
photo is not modified
|
||||
{modified.doy} 3-digit day of year (e.g Julian day) of
|
||||
photo modification time, starting from 1
|
||||
(zero padded)
|
||||
{modified.hour} 2-digit hour of the photo modification time
|
||||
(zero padded); uses creation date if photo
|
||||
is not modified
|
||||
{modified.hour} 2-digit hour of the photo modification time;
|
||||
uses creation date if photo is not modified
|
||||
{modified.min} 2-digit minute of the photo modification
|
||||
time
|
||||
time; uses creation date if photo is not
|
||||
modified
|
||||
{modified.sec} 2-digit second of the photo modification
|
||||
time
|
||||
time; uses creation date if photo is not
|
||||
modified
|
||||
{today.date} Current date in iso format, e.g.
|
||||
'2020-03-22'
|
||||
{today.year} 4-digit year of current date
|
||||
@@ -2224,18 +2309,18 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|
||||
|{created.min}|2-digit minute of the photo creation time|
|
||||
|{created.sec}|2-digit second of the photo creation time|
|
||||
|{created.strftime}|Apply strftime template to file creation date/time. Should be used in form {created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates.|
|
||||
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'|
|
||||
|{modified.year}|4-digit year of photo modification time|
|
||||
|{modified.yy}|2-digit year of photo modification time|
|
||||
|{modified.mm}|2-digit month of the photo modification time (zero padded)|
|
||||
|{modified.month}|Month name in user's locale of the photo modification time|
|
||||
|{modified.mon}|Month abbreviation in the user's locale of the photo modification time|
|
||||
|{modified.dd}|2-digit day of the month (zero padded) of the photo modification time|
|
||||
|{modified.dow}|Day of week in user's locale of the photo modification time|
|
||||
|{modified.doy}|3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded)|
|
||||
|{modified.hour}|2-digit hour of the photo modification time|
|
||||
|{modified.min}|2-digit minute of the photo modification time|
|
||||
|{modified.sec}|2-digit second of the photo modification time|
|
||||
|{modified.date}|Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified|
|
||||
|{modified.year}|4-digit year of photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.yy}|2-digit year of photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.mm}|2-digit month of the photo modification time (zero padded); uses creation date if photo is not modified|
|
||||
|{modified.month}|Month name in user's locale of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.mon}|Month abbreviation in the user's locale of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.dd}|2-digit day of the month (zero padded) of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.dow}|Day of week in user's locale of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.doy}|3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded); uses creation date if photo is not modified|
|
||||
|{modified.hour}|2-digit hour of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.min}|2-digit minute of the photo modification time; uses creation date if photo is not modified|
|
||||
|{modified.sec}|2-digit second of the photo modification time; uses creation date if photo is not modified|
|
||||
|{today.date}|Current date in iso format, e.g. '2020-03-22'|
|
||||
|{today.year}|4-digit year of current date|
|
||||
|{today.yy}|2-digit year of current date|
|
||||
|
||||
@@ -11,12 +11,14 @@ import time
|
||||
import unicodedata
|
||||
|
||||
import click
|
||||
import osxmetadata
|
||||
import yaml
|
||||
|
||||
import osxphotos
|
||||
|
||||
from ._constants import (
|
||||
_EXIF_TOOL_URL,
|
||||
_OSXPHOTOS_NONE_SENTINEL,
|
||||
_PHOTOS_4_VERSION,
|
||||
_UNKNOWN_PLACE,
|
||||
CLI_COLOR_ERROR,
|
||||
@@ -24,6 +26,8 @@ from ._constants import (
|
||||
DEFAULT_EDITED_SUFFIX,
|
||||
DEFAULT_JPEG_QUALITY,
|
||||
DEFAULT_ORIGINAL_SUFFIX,
|
||||
EXTENDED_ATTRIBUTE_NAMES,
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||
SIDECAR_EXIFTOOL,
|
||||
SIDECAR_JSON,
|
||||
SIDECAR_XMP,
|
||||
@@ -179,7 +183,40 @@ 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', '--finder-tag-keywords', '-xattr-template') 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.
|
||||
|
||||
The following attributes may be used with '--xattr-template':
|
||||
|
||||
"""
|
||||
)
|
||||
formatter.write_dl(
|
||||
[
|
||||
(
|
||||
attr,
|
||||
f"{osxmetadata.ATTRIBUTES[attr].help} ({osxmetadata.ATTRIBUTES[attr].constant})",
|
||||
)
|
||||
for attr in EXTENDED_ATTRIBUTE_NAMES
|
||||
]
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys"
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write_text("** Templating System **")
|
||||
formatter.write("\n")
|
||||
@@ -1157,8 +1194,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 +1421,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 +1491,33 @@ 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(
|
||||
"--xattr-template",
|
||||
nargs=2,
|
||||
metavar="ATTRIBUTE TEMPLATE",
|
||||
multiple=True,
|
||||
help="Set extended attribute ATTRIBUTE to TEMPLATE value. Valid attributes are: "
|
||||
f"{', '.join(EXTENDED_ATTRIBUTE_NAMES_QUOTED)}. "
|
||||
"For example, to set Finder comment to the photo's title and description: "
|
||||
"'--xattr-template findercomment \"{title}; {descr}\" "
|
||||
"See Extended Attributes below for additional details on this option.",
|
||||
)
|
||||
@click.option(
|
||||
"--directory",
|
||||
metavar="DIRECTORY",
|
||||
@@ -1587,6 +1658,9 @@ def export(
|
||||
album_keyword,
|
||||
keyword_template,
|
||||
description_template,
|
||||
finder_tag_template,
|
||||
finder_tag_keywords,
|
||||
xattr_template,
|
||||
current_name,
|
||||
convert_to_jpeg,
|
||||
jpeg_quality,
|
||||
@@ -1601,6 +1675,7 @@ def export(
|
||||
download_missing,
|
||||
dest,
|
||||
exiftool,
|
||||
exiftool_path,
|
||||
exiftool_option,
|
||||
exiftool_merge_keywords,
|
||||
exiftool_merge_persons,
|
||||
@@ -1678,7 +1753,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 +1797,9 @@ 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
|
||||
xattr_template = cfg.xattr_template
|
||||
current_name = cfg.current_name
|
||||
convert_to_jpeg = cfg.convert_to_jpeg
|
||||
jpeg_quality = cfg.jpeg_quality
|
||||
@@ -1735,6 +1813,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
|
||||
@@ -1835,6 +1914,19 @@ def export(
|
||||
)
|
||||
raise click.Abort()
|
||||
|
||||
if xattr_template:
|
||||
for attr, _ in xattr_template:
|
||||
if attr not in EXTENDED_ATTRIBUTE_NAMES:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Invalid attribute '{attr}' for --xattr-template; "
|
||||
f"valid values are {', '.join(EXTENDED_ATTRIBUTE_NAMES_QUOTED)}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
raise click.Abort()
|
||||
|
||||
if save_config:
|
||||
verbose_(f"Saving options to file {save_config}")
|
||||
cfg.write_to_file(save_config)
|
||||
@@ -1881,10 +1973,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 +1993,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 +2077,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 +2158,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 +2203,40 @@ 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")
|
||||
# all photo files (not including sidecars) that are part of this export set
|
||||
# used below for applying Finder tags, etc.
|
||||
photo_files = set(
|
||||
export_results.exported
|
||||
+ export_results.new
|
||||
+ export_results.updated
|
||||
+ export_results.exif_updated
|
||||
+ export_results.converted_to_jpeg
|
||||
+ export_results.skipped
|
||||
)
|
||||
|
||||
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:
|
||||
tags_written, tags_skipped = write_finder_tags(
|
||||
p,
|
||||
photo_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 xattr_template:
|
||||
xattr_written, xattr_skipped = write_extended_attributes(
|
||||
p, photo_files, xattr_template
|
||||
)
|
||||
results.xattr_written.extend(xattr_written)
|
||||
results.xattr_skipped.extend(xattr_skipped)
|
||||
|
||||
if fp is not None:
|
||||
fp.close()
|
||||
|
||||
if cleanup:
|
||||
all_files = (
|
||||
@@ -2327,7 +2414,7 @@ def print_photo_info(photos, json=False):
|
||||
|
||||
|
||||
def _query(
|
||||
db=None,
|
||||
photosdb,
|
||||
keyword=None,
|
||||
person=None,
|
||||
album=None,
|
||||
@@ -2386,12 +2473,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,
|
||||
@@ -3167,8 +3254,8 @@ def load_uuid_from_file(filename):
|
||||
|
||||
def write_export_report(report_file, results):
|
||||
|
||||
""" write CSV report with results from export
|
||||
|
||||
"""write CSV report with results from export
|
||||
|
||||
Args:
|
||||
report_file: path to report file
|
||||
results: ExportResults object
|
||||
@@ -3193,6 +3280,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 +3343,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 +3365,8 @@ def write_export_report(report_file, results):
|
||||
"error",
|
||||
"exiftool_warning",
|
||||
"exiftool_error",
|
||||
"extended_attributes_written",
|
||||
"extended_attributes_skipped",
|
||||
]
|
||||
|
||||
try:
|
||||
@@ -3287,7 +3384,7 @@ def write_export_report(report_file, results):
|
||||
|
||||
|
||||
def cleanup_files(dest_path, files_to_keep, fileutil):
|
||||
""" cleanup dest_path by deleting and files and empty directories
|
||||
"""cleanup dest_path by deleting and files and empty directories
|
||||
not in files_to_keep
|
||||
|
||||
Args:
|
||||
@@ -3321,5 +3418,143 @@ 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)
|
||||
|
||||
|
||||
def write_extended_attributes(photo, files, xattr_template):
|
||||
""" Writes extended attributes to exported files
|
||||
|
||||
Args:
|
||||
photo: a PhotoInfo object
|
||||
xattr_template: list of tuples: (attribute name, attribute template)
|
||||
|
||||
Returns:
|
||||
tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating)
|
||||
"""
|
||||
|
||||
attributes = {}
|
||||
for xattr, template_str in xattr_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,
|
||||
)
|
||||
# filter out any template values that didn't match by looking for sentinel
|
||||
rendered = [
|
||||
value for value in rendered if _OSXPHOTOS_NONE_SENTINEL not in value
|
||||
]
|
||||
try:
|
||||
attributes[xattr].extend(rendered)
|
||||
except KeyError:
|
||||
attributes[xattr] = rendered
|
||||
|
||||
written = set()
|
||||
skipped = set()
|
||||
for f in files:
|
||||
md = osxmetadata.OSXMetaData(f)
|
||||
for attr, value in attributes.items():
|
||||
islist = osxmetadata.ATTRIBUTES[attr].list
|
||||
if value:
|
||||
value = ", ".join(value) if not islist else sorted(value)
|
||||
file_value = md.get_attribute(attr)
|
||||
|
||||
if file_value and islist:
|
||||
file_value = sorted(file_value)
|
||||
|
||||
if (not file_value and not value) or file_value == value:
|
||||
# if both not set or both equal, nothing to do
|
||||
# get_attribute returns None if not set and value will be [] if not set so can't directly compare
|
||||
verbose_(f"Skipping extended attribute {attr} for {f}: nothing to do")
|
||||
skipped.add(f)
|
||||
else:
|
||||
verbose_(f"Writing extended attribute {attr} to {f}")
|
||||
md.set_attribute(attr, value)
|
||||
written.add(f)
|
||||
|
||||
return list(written), [f for f in skipped if f not in written]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli() # pylint: disable=no-value-for-parameter
|
||||
|
||||
@@ -108,7 +108,7 @@ SEARCH_CATEGORY_NEIGHBORHOOD = 3
|
||||
SEARCH_CATEGORY_LOCALITY_4 = 4
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_5 = 5
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_6 = 6
|
||||
SEARCH_CATEGORY_CITY = 7
|
||||
SEARCH_CATEGORY_CITY = 7
|
||||
SEARCH_CATEGORY_LOCALITY_8 = 8
|
||||
SEARCH_CATEGORY_NAMED_AREA = 9
|
||||
SEARCH_CATEGORY_ALL_LOCALITY = [
|
||||
@@ -182,4 +182,16 @@ CLI_COLOR_WARNING = "yellow"
|
||||
# Bit masks for --sidecar
|
||||
SIDECAR_JSON = 0x1
|
||||
SIDECAR_EXIFTOOL = 0x2
|
||||
SIDECAR_XMP = 0x4
|
||||
SIDECAR_XMP = 0x4
|
||||
|
||||
# supported attributes for --xattr-template
|
||||
EXTENDED_ATTRIBUTE_NAMES = [
|
||||
"authors",
|
||||
"comment",
|
||||
"copyright",
|
||||
"description",
|
||||
"findercomment",
|
||||
"headline",
|
||||
"keywords",
|
||||
]
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.38.18"
|
||||
__version__ = "0.39.3"
|
||||
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ def get_exiftool_path():
|
||||
|
||||
|
||||
class _ExifToolProc:
|
||||
""" Runs exiftool in a subprocess via Popen
|
||||
Creates a singleton object """
|
||||
"""Runs exiftool in a subprocess via Popen
|
||||
Creates a singleton object"""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
""" create new object or return instance of already created singleton """
|
||||
@@ -44,20 +44,20 @@ class _ExifToolProc:
|
||||
return cls.instance
|
||||
|
||||
def __init__(self, exiftool=None):
|
||||
""" construct _ExifToolProc singleton object or return instance of already created object
|
||||
exiftool: optional path to exiftool binary (if not provided, will search path to find it) """
|
||||
"""construct _ExifToolProc singleton object or return instance of already created object
|
||||
exiftool: optional path to exiftool binary (if not provided, will search path to find it)"""
|
||||
|
||||
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}"
|
||||
)
|
||||
return
|
||||
|
||||
self._exiftool = exiftool or get_exiftool_path()
|
||||
self._process_running = False
|
||||
self._exiftool = exiftool or get_exiftool_path()
|
||||
self._start_proc()
|
||||
|
||||
@property
|
||||
@@ -106,8 +106,8 @@ class _ExifToolProc:
|
||||
|
||||
def _stop_proc(self):
|
||||
""" stop the exiftool process if it's running, otherwise, do nothing """
|
||||
|
||||
if not self._process_running:
|
||||
logging.warning("exiftool process is not running")
|
||||
return
|
||||
|
||||
self._process.stdin.write(b"-stay_open\n")
|
||||
@@ -133,7 +133,7 @@ class ExifTool:
|
||||
""" Basic exiftool interface for reading and writing EXIF tags """
|
||||
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True, flags=None):
|
||||
""" Create ExifTool object
|
||||
"""Create ExifTool object
|
||||
|
||||
Args:
|
||||
file: path to image file
|
||||
@@ -157,15 +157,15 @@ class ExifTool:
|
||||
self._read_exif()
|
||||
|
||||
def setvalue(self, tag, value):
|
||||
""" Set tag to value(s); if value is None, will delete tag
|
||||
"""Set tag to value(s); if value is None, will delete tag
|
||||
|
||||
Args:
|
||||
tag: str; name of tag to set
|
||||
value: str; value to set tag to
|
||||
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
@@ -184,26 +184,26 @@ class ExifTool:
|
||||
return error is None
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
""" Add one or more value(s) to tag
|
||||
"""Add one or more value(s) to tag
|
||||
If more than one value is passed, each value will be added to the tag
|
||||
|
||||
Args:
|
||||
tag: str; tag to set
|
||||
*values: str; one or more values to set
|
||||
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
|
||||
|
||||
Notes: exiftool may add duplicate values for some tags so the caller must ensure
|
||||
the values being added are not already in the EXIF data
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
|
||||
It's up to the caller to know what exiftool will do for each tag
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
but if addvalues called without first calling setvalue, exiftool will add duplicate values
|
||||
"""
|
||||
if not values:
|
||||
@@ -226,7 +226,7 @@ class ExifTool:
|
||||
return error is None
|
||||
|
||||
def run_commands(self, *commands, no_file=False):
|
||||
""" Run commands in the exiftool process and return result.
|
||||
"""Run commands in the exiftool process and return result.
|
||||
|
||||
Args:
|
||||
*commands: exiftool commands to run
|
||||
@@ -266,7 +266,7 @@ class ExifTool:
|
||||
+ b"\n"
|
||||
+ b"-execute\n"
|
||||
)
|
||||
|
||||
|
||||
# send the command
|
||||
self._process.stdin.write(command_str)
|
||||
self._process.stdin.flush()
|
||||
@@ -301,8 +301,8 @@ class ExifTool:
|
||||
return ver.decode("utf-8")
|
||||
|
||||
def asdict(self):
|
||||
""" return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
"""return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
"""
|
||||
json_str, _, _ = self.run_commands("-json")
|
||||
if json_str:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -86,8 +86,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def original_filename(self):
|
||||
""" original filename of the picture
|
||||
Photos 5 mangles filenames upon import """
|
||||
"""original filename of the picture
|
||||
Photos 5 mangles filenames upon import"""
|
||||
if (
|
||||
self._db._db_version <= _PHOTOS_4_VERSION
|
||||
and self.has_raw
|
||||
@@ -106,8 +106,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def date_modified(self):
|
||||
""" image modification date as timezone aware datetime object
|
||||
or None if no modification date set """
|
||||
"""image modification date as timezone aware datetime object
|
||||
or None if no modification date set"""
|
||||
|
||||
# Photos <= 4 provides no way to get date of adjustment and will update
|
||||
# lastmodifieddate anytime photo database record is updated (e.g. adding tags)
|
||||
@@ -492,9 +492,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def ismissing(self):
|
||||
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
|
||||
"""returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
|
||||
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
|
||||
do not immediately get written to disk. In particular, I've noticed that downloading
|
||||
do not immediately get written to disk. In particular, I've noticed that downloading
|
||||
an image from the cloud does not force the database to be updated until something else
|
||||
e.g. an edit, keyword, etc. occurs forcing a database synch
|
||||
The exact process / timing is a mystery to be but be aware that if some photos were recently
|
||||
@@ -539,8 +539,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def shared(self):
|
||||
""" returns True if photos is in a shared iCloud album otherwise false
|
||||
Only valid on Photos 5; returns None on older versions """
|
||||
"""returns True if photos is in a shared iCloud album otherwise false
|
||||
Only valid on Photos 5; returns None on older versions"""
|
||||
if self._db._db_version > _PHOTOS_4_VERSION:
|
||||
return self._info["shared"]
|
||||
else:
|
||||
@@ -548,8 +548,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def uti(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""Returns Uniform Type Identifier (UTI) for the image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self.hasadjustments:
|
||||
@@ -564,8 +564,8 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def uti_original(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the original image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""Returns Uniform Type Identifier (UTI) for the original image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
|
||||
return self._info["raw_pair_info"]["UTI"]
|
||||
@@ -577,9 +577,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def uti_edited(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the edited image
|
||||
if the photo has been edited, otherwise None;
|
||||
for example: public.jpeg
|
||||
"""Returns Uniform Type Identifier (UTI) for the edited image
|
||||
if the photo has been edited, otherwise None;
|
||||
for example: public.jpeg
|
||||
"""
|
||||
if self._db._db_version >= _PHOTOS_5_VERSION:
|
||||
return self.uti if self.hasadjustments else None
|
||||
@@ -588,36 +588,34 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def uti_raw(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the RAW image if there is one
|
||||
for example: com.canon.cr2-raw-image
|
||||
Returns None if no associated RAW image
|
||||
"""Returns Uniform Type Identifier (UTI) for the RAW image if there is one
|
||||
for example: com.canon.cr2-raw-image
|
||||
Returns None if no associated RAW image
|
||||
"""
|
||||
return self._info["UTI_raw"]
|
||||
|
||||
@property
|
||||
def ismovie(self):
|
||||
""" Returns True if file is a movie, otherwise False
|
||||
"""
|
||||
"""Returns True if file is a movie, otherwise False"""
|
||||
return True if self._info["type"] == _MOVIE_TYPE else False
|
||||
|
||||
@property
|
||||
def isphoto(self):
|
||||
""" Returns True if file is an image, otherwise False
|
||||
"""
|
||||
"""Returns True if file is an image, otherwise False"""
|
||||
return True if self._info["type"] == _PHOTO_TYPE else False
|
||||
|
||||
@property
|
||||
def incloud(self):
|
||||
""" Returns True if photo is cloud asset and is synched to cloud
|
||||
False if photo is cloud asset and not yet synched to cloud
|
||||
None if photo is not cloud asset
|
||||
"""Returns True if photo is cloud asset and is synched to cloud
|
||||
False if photo is cloud asset and not yet synched to cloud
|
||||
None if photo is not cloud asset
|
||||
"""
|
||||
return self._info["incloud"]
|
||||
|
||||
@property
|
||||
def iscloudasset(self):
|
||||
""" Returns True if photo is a cloud asset (in an iCloud library),
|
||||
otherwise False
|
||||
"""Returns True if photo is a cloud asset (in an iCloud library),
|
||||
otherwise False
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return (
|
||||
@@ -636,9 +634,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def burst_photos(self):
|
||||
""" If photo is a burst photo, returns list of PhotoInfo objects
|
||||
that are part of the same burst photo set; otherwise returns empty list.
|
||||
self is not included in the returned list """
|
||||
"""If photo is a burst photo, returns list of PhotoInfo objects
|
||||
that are part of the same burst photo set; otherwise returns empty list.
|
||||
self is not included in the returned list"""
|
||||
if self._info["burst"]:
|
||||
burst_uuid = self._info["burstUUID"]
|
||||
return [
|
||||
@@ -656,9 +654,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def path_live_photo(self):
|
||||
""" Returns path to the associated video file for a live photo
|
||||
If photo is not a live photo, returns None
|
||||
If photo is missing, returns None """
|
||||
"""Returns path to the associated video file for a live photo
|
||||
If photo is not a live photo, returns None
|
||||
If photo is missing, returns None"""
|
||||
|
||||
photopath = None
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
@@ -785,9 +783,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def raw_original(self):
|
||||
""" returns True if associated raw image and the raw image is selected in Photos
|
||||
via "Use RAW as Original "
|
||||
otherwise returns False """
|
||||
"""returns True if associated raw image and the raw image is selected in Photos
|
||||
via "Use RAW as Original "
|
||||
otherwise returns False"""
|
||||
return self._info["raw_is_original"]
|
||||
|
||||
@property
|
||||
@@ -841,20 +839,20 @@ class PhotoInfo:
|
||||
Args:
|
||||
template_str: a template string with fields to render
|
||||
none_str: a str to use if template field renders to None, default is "_".
|
||||
path_sep: a single character str to use as path separator when joining
|
||||
path_sep: a single character str to use as path separator when joining
|
||||
fields like folder_album; if not provided, defaults to os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
replacement: str, value to replace any illegal file path characters with; default = ":"
|
||||
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
"""
|
||||
template = PhotoTemplate(self)
|
||||
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
|
||||
return template.render(
|
||||
template_str,
|
||||
none_str=none_str,
|
||||
@@ -877,11 +875,11 @@ class PhotoInfo:
|
||||
return self._info["latitude"]
|
||||
|
||||
def _get_album_uuids(self):
|
||||
""" Return list of album UUIDs this photo is found in
|
||||
|
||||
"""Return list of album UUIDs this photo is found in
|
||||
|
||||
Filters out albums in the trash and any special album types
|
||||
|
||||
Returns: list of album UUIDs
|
||||
Returns: list of album UUIDs
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
version4 = True
|
||||
|
||||
@@ -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_")
|
||||
|
||||
@@ -70,18 +70,18 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
+ "{created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
|
||||
+ "If used with no template will return null value. "
|
||||
+ "See https://strftime.org/ for help on strftime templates.",
|
||||
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
|
||||
"{modified.year}": "4-digit year of photo modification time",
|
||||
"{modified.yy}": "2-digit year of photo modification time",
|
||||
"{modified.mm}": "2-digit month of the photo modification time (zero padded)",
|
||||
"{modified.month}": "Month name in user's locale of the photo modification time",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the photo modification time",
|
||||
"{modified.dd}": "2-digit day of the month (zero padded) of the photo modification time",
|
||||
"{modified.dow}": "Day of week in user's locale of the photo modification time",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded)",
|
||||
"{modified.hour}": "2-digit hour of the photo modification time",
|
||||
"{modified.min}": "2-digit minute of the photo modification time",
|
||||
"{modified.sec}": "2-digit second of the photo modification time",
|
||||
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'; uses creation date if photo is not modified",
|
||||
"{modified.year}": "4-digit year of photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.yy}": "2-digit year of photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.mm}": "2-digit month of the photo modification time (zero padded); uses creation date if photo is not modified",
|
||||
"{modified.month}": "Month name in user's locale of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.dd}": "2-digit day of the month (zero padded) of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.dow}": "Day of week in user's locale of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of photo modification time, starting from 1 (zero padded); uses creation date if photo is not modified",
|
||||
"{modified.hour}": "2-digit hour of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.min}": "2-digit minute of the photo modification time; uses creation date if photo is not modified",
|
||||
"{modified.sec}": "2-digit second of the photo modification time; uses creation date if photo is not modified",
|
||||
# "{modified.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
|
||||
# + "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
|
||||
# + "{modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
|
||||
@@ -149,13 +149,15 @@ MULTI_VALUE_SUBSTITUTIONS = [
|
||||
class PhotoTemplate:
|
||||
""" PhotoTemplate class to render a template string from a PhotoInfo object """
|
||||
|
||||
def __init__(self, photo):
|
||||
""" Inits PhotoTemplate class with photo, non_str, and path_sep
|
||||
def __init__(self, photo, exiftool_path=None):
|
||||
""" Inits PhotoTemplate class with photo
|
||||
|
||||
Args:
|
||||
photo: a PhotoInfo instance.
|
||||
exiftool_path: optional path to exiftool for use with {exiftool:} template; if not provided, will look for exiftool in $PATH
|
||||
"""
|
||||
self.photo = photo
|
||||
self.exiftool_path = exiftool_path
|
||||
|
||||
# holds value of current date/time for {today.x} fields
|
||||
# gets initialized in get_template_value
|
||||
@@ -517,7 +519,7 @@ class PhotoTemplate:
|
||||
if not self.photo.path:
|
||||
values = [None]
|
||||
else:
|
||||
exif = ExifTool(self.photo.path)
|
||||
exif = ExifTool(self.photo.path, exiftool=self.exiftool_path)
|
||||
exifdict = exif.asdict()
|
||||
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
|
||||
subfield = subfield.lower()
|
||||
@@ -677,73 +679,73 @@ class PhotoTemplate:
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).date
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).date
|
||||
)
|
||||
elif field == "modified.year":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).year
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).year
|
||||
)
|
||||
elif field == "modified.yy":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).yy
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).yy
|
||||
)
|
||||
elif field == "modified.mm":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).mm
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).mm
|
||||
)
|
||||
elif field == "modified.month":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).month
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).month
|
||||
)
|
||||
elif field == "modified.mon":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).mon
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).mon
|
||||
)
|
||||
elif field == "modified.dd":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).dd
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).dd
|
||||
)
|
||||
elif field == "modified.dow":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).dow
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).dow
|
||||
)
|
||||
elif field == "modified.doy":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).doy
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).doy
|
||||
)
|
||||
elif field == "modified.hour":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).hour
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).hour
|
||||
)
|
||||
elif field == "modified.min":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).min
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).min
|
||||
)
|
||||
elif field == "modified.sec":
|
||||
value = (
|
||||
DateTimeFormatter(self.photo.date_modified).sec
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
else DateTimeFormatter(self.photo.date).sec
|
||||
)
|
||||
elif field == "today.date":
|
||||
value = DateTimeFormatter(self.today).date
|
||||
|
||||
@@ -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.13",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -357,6 +357,7 @@ CLI_EXIFTOOL = {
|
||||
"XMP:TagsList": "Kids",
|
||||
"XMP:Title": "I found one!",
|
||||
"EXIF:ImageDescription": "Girl holding pumpkin",
|
||||
"EXIF:Make": "Canon",
|
||||
"XMP:Description": "Girl holding pumpkin",
|
||||
"XMP:PersonInImage": "Katie",
|
||||
"XMP:Subject": "Kids",
|
||||
@@ -439,6 +440,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 +1069,100 @@ 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_path_render_template():
|
||||
""" test --exiftool-path with {exiftool:} template rendering """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from osxphotos.__main__ import export
|
||||
from osxphotos.exiftool import ExifTool
|
||||
from osxphotos.utils import noop
|
||||
|
||||
exiftool_source = osxphotos.exiftool.get_exiftool_path()
|
||||
|
||||
# monkey patch get_exiftool_path so it returns None
|
||||
get_exiftool_path = osxphotos.exiftool.get_exiftool_path
|
||||
osxphotos.exiftool.get_exiftool_path = noop
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
tempdir = tempfile.TemporaryDirectory()
|
||||
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",
|
||||
"--filename",
|
||||
"{original_name}_{exiftool:EXIF:Make}",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
"--exiftool-path",
|
||||
exiftool_path,
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert re.search(r"Exporting.*Canon", result.output)
|
||||
|
||||
osxphotos.exiftool.get_exiftool_path = get_exiftool_path
|
||||
|
||||
|
||||
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||
def test_export_exiftool_ignore_date_modified():
|
||||
import glob
|
||||
@@ -3382,9 +3490,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 +4876,335 @@ 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)
|
||||
|
||||
|
||||
def test_export_xattr_template():
|
||||
""" test --xattr template """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
|
||||
from osxmetadata import OSXMetaData
|
||||
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",
|
||||
"--xattr-template",
|
||||
"keywords",
|
||||
"{person}",
|
||||
"--xattr-template",
|
||||
"comment",
|
||||
"{title}",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
|
||||
expected = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
|
||||
expected = [expected] if type(expected) != list else expected
|
||||
assert sorted(md.keywords) == sorted(expected)
|
||||
assert md.comment == CLI_FINDER_TAGS[uuid]["XMP:Title"]
|
||||
|
||||
# run again with --update, should skip writing extended attributes
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"-V",
|
||||
"--xattr-template",
|
||||
"keywords",
|
||||
"{person}",
|
||||
"--xattr-template",
|
||||
"comment",
|
||||
"{title}",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
"--update",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Skipping extended attribute keywords" in result.output
|
||||
assert "Skipping extended attribute comment" in result.output
|
||||
|
||||
# clear tags and run again, should update extended attributes
|
||||
md.keywords = None
|
||||
md.comment = None
|
||||
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"-V",
|
||||
"--xattr-template",
|
||||
"keywords",
|
||||
"{person}",
|
||||
"--xattr-template",
|
||||
"comment",
|
||||
"{title}",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
"--update",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Writing extended attribute keyword" in result.output
|
||||
assert "Writing extended attribute comment" in result.output
|
||||
|
||||
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
|
||||
expected = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
|
||||
expected = [expected] if type(expected) != list else expected
|
||||
assert sorted(md.keywords) == sorted(expected)
|
||||
assert md.comment == CLI_FINDER_TAGS[uuid]["XMP:Title"]
|
||||
|
||||
|
||||
@@ -170,17 +170,21 @@ TEMPLATE_VALUES_DATE_MODIFIED = {
|
||||
}
|
||||
|
||||
TEMPLATE_VALUES_DATE_NOT_MODIFIED = {
|
||||
# uses creation date instead of modified date
|
||||
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
|
||||
"{original_name}": "IMG_1064",
|
||||
"{modified.date}": "_",
|
||||
"{modified.year}": "_",
|
||||
"{modified.yy}": "_",
|
||||
"{modified.mm}": "_",
|
||||
"{modified.month}": "_",
|
||||
"{modified.mon}": "_",
|
||||
"{modified.dd}": "_",
|
||||
"{modified.doy}": "_",
|
||||
"{modified.dow}": "_",
|
||||
"{modified.date}": "2020-02-04",
|
||||
"{modified.year}": "2020",
|
||||
"{modified.yy}": "20",
|
||||
"{modified.mm}": "02",
|
||||
"{modified.month}": "February",
|
||||
"{modified.mon}": "Feb",
|
||||
"{modified.dd}": "04",
|
||||
"{modified.dow}": "Tuesday",
|
||||
"{modified.doy}": "035",
|
||||
"{modified.hour}": "19",
|
||||
"{modified.min}": "07",
|
||||
"{modified.sec}": "38",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user