Added --album-keyword and --person-keyword to CLI, closes #61

This commit is contained in:
Rhet Turnbull 2020-04-27 23:08:59 -07:00
parent 56a000609f
commit b35b071634
7 changed files with 300 additions and 17 deletions

View File

@ -217,6 +217,10 @@ Options:
photos if the RAW photo does not have an
associated jpeg image (e.g. the RAW file was
imported to Photos without a jpeg preview).
--person-keyword Use person in image as keyword/tag when
exporting metadata.
--album-keyword Use album name as keyword/tag when exporting
metadata.
--current-name Use photo's current filename instead of
original filename for export. Note:
Starting with Photos 5, all photos are
@ -814,6 +818,12 @@ For example, in my library, Photos says I have 19,386 photos and 474 movies. Ho
>>>
```
#### `use_persons_as_keywords`
If True, person names (face/person in image) will be used as keywords when exporting metadata with [PhotoInfo](#PhotoInfo) [export()](#export).
#### `use_albums_as_keywords`
If True, album names will be used as keywords when exporting metadata with [PhotoInfo](#PhotoInfo) [export()](#export).
### PhotoInfo
PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library.
@ -958,7 +968,8 @@ Returns True if photo is a panorama, otherwise False.
#### `json()`
Returns a JSON representation of all photo info
#### `export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False)`
#### `export()`
`export(dest, *filename, edited=False, live_photo=False, overwrite=False, increment=True, sidecar_json=False, sidecar_xmp=False, use_photos_export=False, timeout=120, exiftool=False, no_xattr=False)`
Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised).

View File

@ -893,6 +893,16 @@ def query(
"Note: this does not skip RAW photos if the RAW photo does not have an associated jpeg image "
"(e.g. the RAW file was imported to Photos without a jpeg preview).",
)
@click.option(
"--person-keyword",
is_flag = True,
help = "Use person in image as keyword/tag when exporting metadata."
)
@click.option(
"--album-keyword",
is_flag = True,
help = "Use album name as keyword/tag when exporting metadata."
)
@click.option(
"--current-name",
is_flag=True,
@ -983,6 +993,8 @@ def export(
skip_bursts,
skip_live,
skip_raw,
person_keyword,
album_keyword,
current_name,
sidecar,
only_photos,
@ -1145,6 +1157,16 @@ def export(
)
if photos:
# set the persons_as_keywords and albums_as_keywords on the database object
# to control metadata export
photosdb = photos[0]._db
if person_keyword:
# add persons as keywords
photosdb.use_persons_as_keywords = True
if album_keyword:
# add albums as keywords
photosdb.use_albums_as_keywords = True
if export_bursts:
# add the burst_photos to the export set
photos_burst = [p for p in photos if p.burst]

View File

@ -1,3 +1,3 @@
""" version info """
__version__ = "0.28.6"
__version__ = "0.28.7"

View File

@ -24,6 +24,7 @@ from ._constants import (
_PHOTOS_4_VERSION,
_PHOTOS_5_SHARED_PHOTO_PATH,
_TEMPLATE_DIR,
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
)
from .exiftool import ExifTool
@ -304,9 +305,7 @@ class PhotoInfo:
# Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library
# that are missing do not always trigger is_missing = True as happens
# in earlier version so it's possible for this check to fail, if so, return None
logging.debug(
f"Error getting path to RAW file: {filepath}/{glob_str}"
)
logging.debug(f"Error getting path to RAW file: {filepath}/{glob_str}")
photopath = None
else:
photopath = os.path.join(filepath, raw_file[0])
@ -934,18 +933,30 @@ class PhotoInfo:
if self.title:
exif["XMP:Title"] = self.title
keyword_list = []
if self.keywords:
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords)
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
exif["XMP:Subject"] = list(self.keywords)
keyword_list.extend(self.keywords)
person_list = []
if self.persons:
exif["XMP:PersonInImage"] = self.persons
# filter out _UNKNOWN_PERSON
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
if self._db.use_persons_as_keywords and person_list:
keyword_list.extend(person_list)
if self._db.use_albums_as_keywords and self.albums:
keyword_list.extend(self.albums)
if keyword_list:
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = keyword_list
if person_list:
exif["XMP:PersonInImage"] = person_list
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
if "XMP:Subject" in exif:
exif["XMP:Subject"].extend(self.persons)
else:
exif["XMP:Subject"] = self.persons
exif["XMP:Subject"] = list(self.keywords) + person_list
# if self.favorite():
# exif["Rating"] = 5
@ -986,7 +997,34 @@ class PhotoInfo:
xmp_template = Template(
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)
)
xmp_str = xmp_template.render(photo=self)
keyword_list = []
if self.keywords:
keyword_list.extend(self.keywords)
person_list = []
if self.persons:
# filter out _UNKNOWN_PERSON
person_list = [p for p in self.persons if p != _UNKNOWN_PERSON]
if self._db.use_persons_as_keywords and person_list:
keyword_list.extend(person_list)
if self._db.use_albums_as_keywords and self.albums:
keyword_list.extend(self.albums)
subject_list = []
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
subject_list = list(self.keywords) + person_list
xmp_str = xmp_template.render(
photo=self,
keywords=keyword_list,
persons=person_list,
subjects=subject_list,
)
# remove extra lines that mako inserts from template
xmp_str = "\n".join(
[line for line in xmp_str.split("\n") if line.strip() != ""]

View File

@ -77,6 +77,12 @@ class PhotosDB:
# set up the data structures used to store all the Photo database info
# if True, will treat persons as keywords when exporting metadata
self.use_persons_as_keywords = False
# if True, will treat albums as keywords when exporting metadata
self.use_albums_as_keywords = False
# Path to the Photos library database file
# photos.db in the photos library database/ directory
self._dbfile = None

View File

@ -79,16 +79,16 @@
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
${dc_description(photo.description)}
${dc_title(photo.title)}
${dc_subject(photo.keywords + photo.persons)}
${dc_subject(subjects)}
${dc_datecreated(photo.date)}
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
${iptc_personinimage(photo.persons)}
${iptc_personinimage(persons)}
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
${dk_tagslist(photo.keywords)}
${dk_tagslist(keywords)}
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>

View File

@ -482,6 +482,90 @@ def test_exiftool_json_sidecar():
assert json_got[k] == v
def test_exiftool_json_sidecar_use_persons_keyword():
import osxphotos
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb.use_persons_as_keywords = True
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girls with pumpkins",
"XMP:Description": "Girls with pumpkins",
"XMP:Title": "Can we carry this?",
"XMP:TagsList": ["Kids", "Suzy", "Katie"],
"IPTC:Keywords": ["Kids", "Suzy", "Katie"],
"XMP:PersonInImage": ["Suzy", "Katie"],
"XMP:Subject": ["Kids", "Suzy", "Katie"],
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00",
"EXIF:ModifyDate": "2019:11:24 13:09:17"}]
"""
)[0]
json_got = photos[0]._exiftool_json_sidecar()
json_got = json.loads(json_got)[0]
# some gymnastics to account for different sort order in different pythons
# some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():
if type(v) in (list, tuple):
assert sorted(json_expected[k]) == sorted(v)
else:
assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
def test_exiftool_json_sidecar_use_albums_keyword():
import osxphotos
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb.use_albums_as_keywords = True
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girls with pumpkins",
"XMP:Description": "Girls with pumpkins",
"XMP:Title": "Can we carry this?",
"XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"],
"IPTC:Keywords": ["Kids", "Pumpkin Farm", "Test Album"],
"XMP:PersonInImage": ["Suzy", "Katie"],
"XMP:Subject": ["Kids", "Suzy", "Katie"],
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00",
"EXIF:ModifyDate": "2019:11:24 13:09:17"}]
"""
)[0]
json_got = photos[0]._exiftool_json_sidecar()
json_got = json.loads(json_got)[0]
# some gymnastics to account for different sort order in different pythons
# some gymnastics to account for different sort order in different pythons
for k, v in json_got.items():
if type(v) in (list, tuple):
assert sorted(json_expected[k]) == sorted(v)
else:
assert json_expected[k] == v
for k, v in json_expected.items():
if type(v) in (list, tuple):
assert sorted(json_got[k]) == sorted(v)
else:
assert json_got[k] == v
def test_xmp_sidecar():
import osxphotos
@ -539,3 +623,125 @@ def test_xmp_sidecar():
for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines):
assert line_expected == line_got
def test_xmp_sidecar_use_persons_keyword():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb.use_persons_as_keywords = True
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar()
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines):
assert line_expected == line_got
def test_xmp_sidecar_use_albums_keyword():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photosdb.use_albums_as_keywords = True
photos = photosdb.photos(uuid=[UUID_DICT["xmp"]])
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<dc:description>Girls with pumpkins</dc:description>
<dc:title>Can we carry this?</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Seq>
</dc:subject>
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
<rdf:li>Suzy</rdf:li>
<rdf:li>Katie</rdf:li>
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
<digiKam:TagsList>
<rdf:Seq>
<rdf:li>Kids</rdf:li>
<rdf:li>Pumpkin Farm</rdf:li>
<rdf:li>Test Album</rdf:li>
</rdf:Seq>
</digiKam:TagsList>
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"""
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
xmp_got = photos[0]._xmp_sidecar()
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines):
assert line_expected == line_got