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 photos if the RAW photo does not have an
associated jpeg image (e.g. the RAW file was associated jpeg image (e.g. the RAW file was
imported to Photos without a jpeg preview). 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 --current-name Use photo's current filename instead of
original filename for export. Note: original filename for export. Note:
Starting with Photos 5, all photos are 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 ### PhotoInfo
PhotosDB.photos() returns a list of PhotoInfo objects. Each PhotoInfo object represents a single photo in the Photos library. 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()` #### `json()`
Returns a JSON representation of all photo info 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. Export photo from the Photos library to another destination on disk.
- dest: must be valid destination path as str (or exception raised). - 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 " "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).", "(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( @click.option(
"--current-name", "--current-name",
is_flag=True, is_flag=True,
@@ -983,6 +993,8 @@ def export(
skip_bursts, skip_bursts,
skip_live, skip_live,
skip_raw, skip_raw,
person_keyword,
album_keyword,
current_name, current_name,
sidecar, sidecar,
only_photos, only_photos,
@@ -1145,6 +1157,16 @@ def export(
) )
if photos: 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: if export_bursts:
# add the burst_photos to the export set # add the burst_photos to the export set
photos_burst = [p for p in photos if p.burst] photos_burst = [p for p in photos if p.burst]

View File

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

View File

@@ -24,6 +24,7 @@ from ._constants import (
_PHOTOS_4_VERSION, _PHOTOS_4_VERSION,
_PHOTOS_5_SHARED_PHOTO_PATH, _PHOTOS_5_SHARED_PHOTO_PATH,
_TEMPLATE_DIR, _TEMPLATE_DIR,
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME, _XMP_TEMPLATE_NAME,
) )
from .exiftool import ExifTool 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 # 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 # 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 # in earlier version so it's possible for this check to fail, if so, return None
logging.debug( logging.debug(f"Error getting path to RAW file: {filepath}/{glob_str}")
f"Error getting path to RAW file: {filepath}/{glob_str}"
)
photopath = None photopath = None
else: else:
photopath = os.path.join(filepath, raw_file[0]) photopath = os.path.join(filepath, raw_file[0])
@@ -934,18 +933,30 @@ class PhotoInfo:
if self.title: if self.title:
exif["XMP:Title"] = self.title exif["XMP:Title"] = self.title
keyword_list = []
if self.keywords: if self.keywords:
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords) keyword_list.extend(self.keywords)
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
exif["XMP:Subject"] = list(self.keywords)
person_list = []
if self.persons: 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" # Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
if "XMP:Subject" in exif: exif["XMP:Subject"] = list(self.keywords) + person_list
exif["XMP:Subject"].extend(self.persons)
else:
exif["XMP:Subject"] = self.persons
# if self.favorite(): # if self.favorite():
# exif["Rating"] = 5 # exif["Rating"] = 5
@@ -986,7 +997,34 @@ class PhotoInfo:
xmp_template = Template( xmp_template = Template(
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME) 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 # remove extra lines that mako inserts from template
xmp_str = "\n".join( xmp_str = "\n".join(
[line for line in xmp_str.split("\n") if line.strip() != ""] [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 # 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 # Path to the Photos library database file
# photos.db in the photos library database/ directory # photos.db in the photos library database/ directory
self._dbfile = None self._dbfile = None

View File

@@ -79,16 +79,16 @@
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"> xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
${dc_description(photo.description)} ${dc_description(photo.description)}
${dc_title(photo.title)} ${dc_title(photo.title)}
${dc_subject(photo.keywords + photo.persons)} ${dc_subject(subjects)}
${dc_datecreated(photo.date)} ${dc_datecreated(photo.date)}
</rdf:Description> </rdf:Description>
<rdf:Description rdf:about='' <rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'> xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
${iptc_personinimage(photo.persons)} ${iptc_personinimage(persons)}
</rdf:Description> </rdf:Description>
<rdf:Description rdf:about='' <rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'> xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
${dk_tagslist(photo.keywords)} ${dk_tagslist(keywords)}
</rdf:Description> </rdf:Description>
<rdf:Description rdf:about='' <rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'> 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 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(): def test_xmp_sidecar():
import osxphotos import osxphotos
@@ -539,3 +623,125 @@ def test_xmp_sidecar():
for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines): for line_expected, line_got in zip(xmp_expected_lines, xmp_got_lines):
assert line_expected == line_got 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