Compare commits

...

18 Commits

Author SHA1 Message Date
Rhet Turnbull
c7c5320587 Fix for issue #39 2020-11-02 05:53:11 -08:00
Rhet Turnbull
cd710771cd Updated CHANGELOG.md 2020-11-01 09:20:21 -08:00
Rhet Turnbull
663e33bc17 Added --ignore-date-modified flag, issue #247 2020-11-01 09:13:45 -08:00
Rhet Turnbull
3660b6360a Updated CHANGELOG.md 2020-10-31 22:19:34 -07:00
Rhet Turnbull
11459d1da4 Updated --exiftool to set dates/times as Photos does, issue #247 2020-10-31 22:11:00 -07:00
Rhet Turnbull
fd14242022 Version bump 2020-10-31 21:05:42 -07:00
Rhet Turnbull
6ac311199e Partial fix for issue #247 on Mojave 2020-10-31 21:04:44 -07:00
Rhet Turnbull
13df6a2395 Add @hhoeck as a contributor 2020-10-31 10:10:21 -07:00
Rhet Turnbull
28dce72a67 Add @agprimatic as a contributor 2020-10-31 10:10:07 -07:00
Rhet Turnbull
e5548ed160 Add @grundsch as a contributor 2020-10-31 10:09:55 -07:00
Rhet Turnbull
5714509765 Add @dethi as a contributor 2020-10-31 10:09:24 -07:00
Rhet Turnbull
46b62af4e2 Add @jystervinou as a contributor 2020-10-31 10:09:06 -07:00
Rhet Turnbull
01ea88fe57 Add @dmd as a contributor 2020-10-31 10:08:43 -07:00
Rhet Turnbull
e6d043ab65 Add @hshore29 as a contributor 2020-10-31 10:08:18 -07:00
Rhet Turnbull
5b1174db5d Add @PabloKohan as a contributor 2020-10-31 10:07:12 -07:00
Rhet Turnbull
9cff8e89c6 Add @mwort as a contributor 2020-10-31 10:03:13 -07:00
Rhet Turnbull
1553563629 Add @britiscurious as a contributor 2020-10-31 10:00:42 -07:00
Rhet Turnbull
db262f58b0 Updated CHANGELOG.md 2020-10-31 09:01:53 -07:00
17 changed files with 547 additions and 120 deletions

106
.all-contributorsrc Normal file
View File

@@ -0,0 +1,106 @@
{
"projectName": "osxphotos",
"projectOwner": "RhetTbull",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": true,
"commitConvention": "none",
"contributors": [
{
"login": "britiscurious",
"name": "britiscurious",
"avatar_url": "https://avatars1.githubusercontent.com/u/25646439?v=4",
"profile": "https://github.com/britiscurious",
"contributions": [
"doc",
"code"
]
},
{
"login": "mwort",
"name": "Michel Wortmann",
"avatar_url": "https://avatars3.githubusercontent.com/u/8170417?v=4",
"profile": "https://github.com/mwort",
"contributions": [
"code"
]
},
{
"login": "PabloKohan",
"name": "Pablo 'merKur' Kohan",
"avatar_url": "https://avatars3.githubusercontent.com/u/8790976?v=4",
"profile": "https://github.com/PabloKohan",
"contributions": [
"code"
]
},
{
"login": "hshore29",
"name": "hshore29",
"avatar_url": "https://avatars2.githubusercontent.com/u/7023497?v=4",
"profile": "https://github.com/hshore29",
"contributions": [
"code"
]
},
{
"login": "dmd",
"name": "Daniel M. Drucker",
"avatar_url": "https://avatars0.githubusercontent.com/u/41439?v=4",
"profile": "http://3e.org/",
"contributions": [
"code"
]
},
{
"login": "jystervinou",
"name": "Jean-Yves Stervinou",
"avatar_url": "https://avatars3.githubusercontent.com/u/132356?v=4",
"profile": "https://github.com/jystervinou",
"contributions": [
"code"
]
},
{
"login": "dethi",
"name": "Thibault Deutsch",
"avatar_url": "https://avatars2.githubusercontent.com/u/1011520?v=4",
"profile": "https://dethi.me/",
"contributions": [
"code"
]
},
{
"login": "grundsch",
"name": "grundsch",
"avatar_url": "https://avatars0.githubusercontent.com/u/3874928?v=4",
"profile": "https://github.com/grundsch",
"contributions": [
"code"
]
},
{
"login": "agprimatic",
"name": "Ag Primatic",
"avatar_url": "https://avatars1.githubusercontent.com/u/4685054?v=4",
"profile": "https://github.com/agprimatic",
"contributions": [
"code"
]
},
{
"login": "hhoeck",
"name": "Horst Höck",
"avatar_url": "https://avatars1.githubusercontent.com/u/6313998?v=4",
"profile": "https://github.com/hhoeck",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7
}

View File

@@ -4,6 +4,34 @@ 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.36.5](https://github.com/RhetTbull/osxphotos/compare/v0.36.4...v0.36.5)
> 1 November 2020
- Added --ignore-date-modified flag, issue #247 [`663e33b`](https://github.com/RhetTbull/osxphotos/commit/663e33bc1709f767e1a08242f6bfe86a3fc78552)
#### [v0.36.4](https://github.com/RhetTbull/osxphotos/compare/v0.36.2...v0.36.4)
> 1 November 2020
- Updated --exiftool to set dates/times as Photos does, issue #247 [`11459d1`](https://github.com/RhetTbull/osxphotos/commit/11459d1da4d7d13e36e9db4bdc940b74baad9d11)
- Partial fix for issue #247 on Mojave [`6ac3111`](https://github.com/RhetTbull/osxphotos/commit/6ac311199e9f7afe6170cbbd68ceaa1bb9f0682b)
- Add @mwort as a contributor [`9cff8e8`](https://github.com/RhetTbull/osxphotos/commit/9cff8e89c6e939d3d371a4f60649f6e5595a55b9)
#### [v0.36.2](https://github.com/RhetTbull/osxphotos/compare/v0.36.1...v0.36.2)
> 31 October 2020
- Fixed handling of date_modified for Catalina, issue #247 [`0cce234`](https://github.com/RhetTbull/osxphotos/commit/0cce234a8cbba63dc1cba439c06fe9de078ff480)
#### [v0.36.1](https://github.com/RhetTbull/osxphotos/compare/v0.36.0...v0.36.1)
> 30 October 2020
- Added --has-comment/--has-likes to CLI, issue #240 [`c5dba8c`](https://github.com/RhetTbull/osxphotos/commit/c5dba8c89bba35d7a77e087b180b2a3d7b94280a)
- Cleaned up as_dict/asdict, issue #144, #188 [`603dabb`](https://github.com/RhetTbull/osxphotos/commit/603dabb8f420a89e993d5aadcd3a5614bbb262dd)
- Updated README.md [`d16932d`](https://github.com/RhetTbull/osxphotos/commit/d16932d0fd8d160ccf44e9842329d5933dc25b36)
#### [v0.36.0](https://github.com/RhetTbull/osxphotos/compare/v0.35.7...v0.36.0)
> 26 October 2020

View File

@@ -1,8 +1,10 @@
# OSXPhotos
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
[![Python package](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)](https://github.com/RhetTbull/osxphotos/workflows/Python%20package/badge.svg)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
- [OSXPhotos](#osxphotos)
* [What is osxphotos?](#what-is-osxphotos)
@@ -327,6 +329,11 @@ Options:
exiftool may be installed from
https://exiftool.org/. Cannot be used with
--export-as-hardlink.
--ignore-date-modified If used with --exiftool or --sidecar, will
ignore the photo modification date and set
EXIF:ModifyDate to EXIF:DateTimeOriginal;
this is consistent with how Photos handles
the EXIF:ModifyDate tag.
--directory DIRECTORY Optional template for specifying name of
output directory in the form
'{name,DEFAULT}'. See below for additional
@@ -1988,21 +1995,37 @@ If you have an interesting example that shows usage of this package, submit an i
Testing against "real world" Photos libraries would be especially helpful. If you discover issues in testing against your Photos libraries, please open an issue. I've done extensive testing against my own Photos library but that's a since data point and I'm certain there are issues lurking in various edge cases I haven't discovered yet.
### Contributors
Thank-you to the following people who have contributed to improving osxphotos! If I've inadvertently left you off, please open an issue or send me a note.
### Contributors ✨
- [britiscurious](https://github.com/britiscurious)
- [Michel Wortmann](https://github.com/mwort)
- [hshore29](https://github.com/hshore29)
- [Pablo 'merKur' Kohan](https://github.com/PabloKohan)
- [Jean-Yves Stervinou](https://github.com/jystervinou)
- [Thibault Deutsch](https://github.com/dethi)
- [grundsch](https://github.com/grundsch)
- [Ag Primatic](https://github.com/agprimatic)
- [Daniel M. Drucker](https://github.com/dmd)
- [Horst Höck](https://github.com/hhoeck)
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://github.com/britiscurious"><img src="https://avatars1.githubusercontent.com/u/25646439?v=4?s=100" width="100px;" alt=""/><br /><sub><b>britiscurious</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Documentation">📖</a> <a href="https://github.com/RhetTbull/osxphotos/commits?author=britiscurious" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/mwort"><img src="https://avatars3.githubusercontent.com/u/8170417?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michel Wortmann</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=mwort" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/PabloKohan"><img src="https://avatars3.githubusercontent.com/u/8790976?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Pablo 'merKur' Kohan</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=PabloKohan" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hshore29"><img src="https://avatars2.githubusercontent.com/u/7023497?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hshore29</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hshore29" title="Code">💻</a></td>
<td align="center"><a href="http://3e.org/"><img src="https://avatars0.githubusercontent.com/u/41439?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel M. Drucker</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dmd" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jystervinou"><img src="https://avatars3.githubusercontent.com/u/132356?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jean-Yves Stervinou</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jystervinou" title="Code">💻</a></td>
<td align="center"><a href="https://dethi.me/"><img src="https://avatars2.githubusercontent.com/u/1011520?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Thibault Deutsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=dethi" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/grundsch"><img src="https://avatars0.githubusercontent.com/u/3874928?v=4?s=100" width="100px;" alt=""/><br /><sub><b>grundsch</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=grundsch" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/agprimatic"><img src="https://avatars1.githubusercontent.com/u/4685054?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ag Primatic</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=agprimatic" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hhoeck"><img src="https://avatars1.githubusercontent.com/u/6313998?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Horst Höck</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=hhoeck" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
## Known Bugs
@@ -2035,5 +2058,4 @@ For additional details about how osxphotos is implemented or if you would like t
## Acknowledgements
This project was originally inspired by [photo-export](https://github.com/patrikhson/photo-export) by Patrick Fältström, Copyright (c) 2015 Patrik Fältström paf@frobbit.se
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.
I use [py-applescript](https://github.com/rdhyee/py-applescript) by "Raymond Yee / rdhyee" to interact with Photos. Rather than import this package, I included the entire package (which is published as public domain code) in a private package to prevent ambiguity with other applescript packages on PyPi. py-applescript uses a native bridge via PyObjC and is very fast compared to the other osascript based packages.

View File

@@ -1290,6 +1290,13 @@ def query(
"exiftool may be installed from https://exiftool.org/. "
"Cannot be used with --export-as-hardlink.",
)
@click.option(
"--ignore-date-modified",
is_flag=True,
help="If used with --exiftool or --sidecar, will ignore the photo "
"modification date and set EXIF:ModifyDate to EXIF:DateTimeOriginal; "
"this is consistent with how Photos handles the EXIF:ModifyDate tag.",
)
@click.option(
"--directory",
metavar="DIRECTORY",
@@ -1389,6 +1396,7 @@ def export(
download_missing,
dest,
exiftool,
ignore_date_modified,
portrait,
not_portrait,
screenshot,
@@ -1663,6 +1671,7 @@ def export(
use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
)
results_exported.extend(results.exported)
results_new.extend(results.new)
@@ -1712,6 +1721,7 @@ def export(
use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
)
results_exported.extend(results.exported)
results_new.extend(results.new)
@@ -2218,6 +2228,7 @@ def export_photo(
use_photos_export=False,
convert_to_jpeg=False,
jpeg_quality=1.0,
ignore_date_modified=False,
):
""" Helper function for export that does the actual export
@@ -2251,6 +2262,7 @@ def export_photo(
use_photos_export: boolean; if True forces the use of AppleScript to export even if photo not missing
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
Returns:
list of path(s) of exported photo or None if photo was missing
@@ -2305,10 +2317,13 @@ def export_photo(
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
use_photos_export = (
download_missing and (photo.ismissing or not os.path.exists(photo.path))
if not use_photos_export
else True
use_photos_export = use_photos_export or (
download_missing
and (
photo.ismissing
or not os.path.exists(photo.path)
or (export_edited and photo.path_edited is None)
)
)
# export the photo to each path in dest_paths
@@ -2339,6 +2354,7 @@ def export_photo(
touch_file=touch_file,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
)
results_exported.extend(export_results.exported)
@@ -2400,6 +2416,7 @@ def export_photo(
touch_file=touch_file,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
)
results_exported.extend(export_results_edited.exported)

View File

@@ -1,4 +1,4 @@
""" version info """
__version__ = "0.36.2"
__version__ = "0.36.6"

View File

@@ -5,6 +5,7 @@
_export_photo
_write_exif_data
_exiftool_json_sidecar
_exiftool_dict
_xmp_sidecar
_write_sidecar
"""
@@ -308,6 +309,7 @@ def export2(
touch_file=False,
convert_to_jpeg=False,
jpeg_quality=1.0,
ignore_date_modified=False,
):
""" export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -350,6 +352,7 @@ def export2(
touch_file: (boolean, default=False); if True, sets file's modification time upon photo date
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg
jpeg_quality: float in range 0.0 <= jpeg_quality <= 1.0. A value of 1.0 specifies use best quality, a value of 0.0 specifies use maximum compression.
ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
where each field is a list of file paths
@@ -698,6 +701,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
if not dry_run:
try:
@@ -743,6 +747,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
)[0]
if old_data != current_data:
@@ -758,6 +763,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
export_db.set_exifdata_for_file(
exported_file,
@@ -766,6 +772,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
),
)
export_db.set_stat_exif_for_file(
@@ -781,6 +788,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
export_db.set_exifdata_for_file(
@@ -790,6 +798,7 @@ def export2(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
),
)
export_db.set_stat_exif_for_file(
@@ -997,21 +1006,30 @@ def _write_exif_data(
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
ignore_date_modified=False,
):
""" write exif data to image file at filepath
filepath: full path to the image file """
Args:
filepath: full path to the image file
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
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
"""
if not os.path.exists(filepath):
raise FileNotFoundError(f"Could not find file {filepath}")
exiftool = ExifTool(filepath)
exif_info = json.loads(
self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
)[0]
exif_info = self._exiftool_dict(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
for exiftag, val in exif_info.items():
if exiftag == "_CreatedBy":
continue
if type(val) == list:
# more than one, set first value the add additional values
exiftool.setvalue(exiftag, val.pop(0))
@@ -1022,37 +1040,46 @@ def _write_exif_data(
exiftool.setvalue(exiftag, val)
def _exiftool_json_sidecar(
def _exiftool_dict(
self,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
ignore_date_modified=False,
):
""" return json string of EXIF details in exiftool sidecar format
Does not include all the EXIF fields as those are likely already in the image
""" 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:
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
Exports the following:
FileName
ImageDescription
Description
Title
TagsList
Keywords (may include album name, person name, or template)
Subject
PersonInImage
GPSLatitude, GPSLongitude
GPSPosition
GPSLatitudeRef, GPSLongitudeRef
DateTimeOriginal
OffsetTimeOriginal
ModifyDate """
description_template: (list of strings); list of template strings to render for the description
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
Returns: dict with exiftool tags / values
Exports the following:
EXIF:ImageDescription
XMP:Description (may include template)
XMP:Title
XMP:TagsList
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:PersonInImage
EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal
EXIF:ModifyDate
IPTC:DigitalCreationDate
IPTC:DateCreated
"""
exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
if description_template is not None:
description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
@@ -1114,15 +1141,16 @@ def _exiftool_json_sidecar(
keyword_list.extend(rendered_keywords)
if keyword_list:
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = keyword_list
exif["XMP:TagsList"] = keyword_list.copy()
exif["IPTC:Keywords"] = keyword_list.copy()
if person_list:
exif["XMP:PersonInImage"] = person_list
exif["XMP:PersonInImage"] = person_list.copy()
if self.keywords or person_list:
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
# only use Photos' keywords for subject
exif["XMP:Subject"] = list(self.keywords) + person_list
# only use Photos' keywords for subject (e.g. don't include template values)
exif["XMP:Subject"] = self.keywords.copy() + person_list.copy()
# if self.favorite():
# exif["Rating"] = 5
@@ -1137,22 +1165,86 @@ def _exiftool_json_sidecar(
exif["EXIF:GPSLongitudeRef"] = lon_ref
# process date/time and timezone offset
# Photos exports the following fields and sets modify date to creation date
# [EXIF] Modify Date : 2020:10:30 00:00:00
# [EXIF] Date/Time Original : 2020:10:30 00:00:00
# [EXIF] Create Date : 2020:10:30 00:00:00
# [IPTC] Digital Creation Date : 2020:10:30
# [IPTC] Date Created : 2020:10:30
#
# This code deviates from Photos in one regard:
# if photo has modification date, use it otherwise use creation date
date = self.date
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:CreateDate"] = datetimeoriginal
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:OffsetTimeOriginal"] = offsettime
if self.date_modified is not None:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
dateoriginal = date.strftime("%Y:%m:%d")
exif["IPTC:DigitalCreationDate"] = dateoriginal
exif["IPTC:DateCreated"] = dateoriginal
json_str = json.dumps([exif])
return json_str
if self.date_modified is not None and not ignore_date_modified:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
else:
exif["EXIF:ModifyDate"] = self.date.strftime("%Y:%m:%d %H:%M:%S")
return exif
def _exiftool_json_sidecar(
self,
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
ignore_date_modified=False,
):
""" 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:
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
description_template: (list of strings); list of template strings to render for the description
ignore_date_modified: if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
Returns: dict with exiftool tags / values
Exports the following:
EXIF:ImageDescription
XMP:Description (may include template)
XMP:Title
XMP:TagsList
IPTC:Keywords (may include album name, person name, or template)
XMP:Subject
XMP:PersonInImage
EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal
EXIF:ModifyDate
IPTC:DigitalCreationDate
IPTC:DateCreated
"""
exif = self._exiftool_dict(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
ignore_date_modified=ignore_date_modified,
)
return json.dumps([exif])
def _xmp_sidecar(

View File

@@ -53,6 +53,7 @@ class PhotoInfo:
export,
export2,
_export_photo,
_exiftool_dict,
_exiftool_json_sidecar,
_write_exif_data,
_write_sidecar,
@@ -60,7 +61,7 @@ class PhotoInfo:
ExportResults,
)
from ._photoinfo_scoreinfo import score, ScoreInfo
from ._photoinfo_comments import comments, likes
from ._photoinfo_comments import comments, likes
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
@@ -70,7 +71,11 @@ class PhotoInfo:
@property
def filename(self):
""" filename of the picture """
if self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original:
if (
self._db._db_version <= _PHOTOS_4_VERSION
and self.has_raw
and self.raw_original
):
# return the JPEG version as that's what Photos 5+ does
return self._info["raw_pair_info"]["filename"]
else:
@@ -80,7 +85,11 @@ class PhotoInfo:
def original_filename(self):
""" original filename of the picture
Photos 5 mangles filenames upon import """
if self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original:
if (
self._db._db_version <= _PHOTOS_4_VERSION
and self.has_raw
and self.raw_original
):
# return the JPEG version as that's what Photos 5+ does
return self._info["raw_pair_info"]["originalFilename"]
else:
@@ -95,12 +104,20 @@ class PhotoInfo:
def date_modified(self):
""" image modification date as timezone aware datetime object
or None if no modification date set """
imagedate = self._info["lastmodifieddate"]
if imagedate:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
return imagedate.astimezone(tz=tz)
# Photos <= 4 provides no way to get date of adjustment and will update
# lastmodifieddate anytime photo database record is updated (e.g. adding tags)
# only report lastmodified date for Photos <=4 if photo is edited;
# even in this case, the date could be incorrect
if self.hasadjustments or self._db._db_version > _PHOTOS_4_VERSION:
imagedate = self._info["lastmodifieddate"]
if imagedate:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
return imagedate.astimezone(tz=tz)
else:
return None
else:
return None
@@ -179,18 +196,15 @@ class PhotoInfo:
""" absolute path on disk of the edited picture """
""" None if photo has not been edited """
# TODO: break this code into a _path_edited_4 and _path_edited_5
# version to simplify the big if/then; same for path_live_photo
try:
return self._path_edited
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
self._path_edited = self._path_edited_4()
return self._path_edited
else:
self._path_edited = self._path_edited_5()
return self._path_edited
return self._path_edited
def _path_edited_5(self):
""" return path_edited for Photos >= 5 """
@@ -248,8 +262,6 @@ class PhotoInfo:
# if self._info["isMissing"] == 1:
# photopath = None # path would be meaningless until downloaded
# logging.debug(photopath)
return photopath
def _path_edited_4(self):
@@ -282,7 +294,7 @@ class PhotoInfo:
# could be elsewhere--I haven't figured out this logic yet
# first see if it's in 00
photopath = os.path.join(
library, "resources", "media", "version", folder_id, "00", filename,
library, "resources", "media", "version", folder_id, "00", filename
)
if not os.path.isfile(photopath):
@@ -841,7 +853,7 @@ class PhotoInfo:
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
replacement=replacement
replacement=replacement,
)
@property
@@ -1026,6 +1038,7 @@ class PhotoInfo:
def json(self):
""" Return JSON representation """
def default(o):
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-10-09T16:14:42Z</date>
<date>2020-11-01T02:34:49Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-10-10T05:21:03Z</date>
<date>2020-11-01T02:34:49Z</date>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-10-04T23:43:17Z</date>
<date>2020-11-01T02:34:46Z</date>
</dict>
</plist>

View File

@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key>
<date>2019-07-27T13:16:43Z</date>
<key>SnapshotLastValidated</key>
<date>2020-10-10T05:22:36Z</date>
<date>2020-11-01T02:34:46Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>

View File

@@ -324,6 +324,24 @@ CLI_EXIFTOOL = {
}
}
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED = {
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
"File:FileName": "wedding.jpg",
"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": "wedding",
"IPTC:Keywords": "wedding",
"XMP:PersonInImage": "Maria",
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2019:04:15",
"IPTC:DateCreated": "2019:04:15",
"EXIF:ModifyDate": "2019:04:15 14:40:24",
}
}
LABELS_JSON = {
"labels": {
"Plant": 7,
@@ -931,6 +949,38 @@ 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_ignore_date_modified():
import glob
import os
import os.path
from osxphotos.__main__ import export
from osxphotos.exiftool import ExifTool
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED:
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_6),
".",
"-V",
"--exiftool",
"--ignore-date-modified",
"--uuid",
f"{uuid}",
],
)
assert result.exit_code == 0
exif = ExifTool(CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"]).asdict()
for key in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]:
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
def test_export_edited_suffix():
""" test export with --edited-suffix """
import glob
@@ -2632,16 +2682,21 @@ def test_export_sidecar_keyword_template():
json_expected = json.loads(
"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girl holding pumpkin",
"XMP:Description": "Girl holding pumpkin",
"XMP:Title": "I found one!",
"XMP:TagsList": ["Kids", "Multi Keyword", "Test Album", "Pumpkin Farm"],
"IPTC:Keywords": ["Kids", "Multi Keyword", "Test Album", "Pumpkin Farm"],
"XMP:PersonInImage": ["Katie"],
"XMP:Subject": ["Kids", "Katie"],
"EXIF:DateTimeOriginal": "2018:09:28 16:07:07",
"EXIF:OffsetTimeOriginal": "-04:00"}]"""
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Girl holding pumpkin",
"XMP:Description": "Girl holding pumpkin",
"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"],
"XMP:Subject": ["Kids", "Katie"],
"EXIF:DateTimeOriginal": "2018:09:28 16:07:07",
"EXIF:CreateDate": "2018:09:28 16:07:07",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2018:09:28",
"IPTC:DateCreated": "2018:09:28",
"EXIF:ModifyDate": "2018:09:28 16:07:07"}]
"""
)[0]
with open("Pumkins2.jpg.json", "r") as json_file:

View File

@@ -67,18 +67,37 @@ XMP_FILENAME = "Pumkins1.jpg.xmp"
XMP_JPG_FILENAME = "Pumkins1.jpg"
EXIF_JSON_UUID = UUID_DICT["has_adjustments"]
EXIF_JSON_EXPECTED = (
'[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", '
'"EXIF:ImageDescription": "Bride Wedding day", '
'"XMP:Description": "Bride Wedding day", '
'"XMP:TagsList": ["wedding"], '
'"IPTC:Keywords": ["wedding"], '
'"XMP:PersonInImage": ["Maria"], '
'"XMP:Subject": ["wedding", "Maria"], '
'"EXIF:DateTimeOriginal": "2019:04:15 14:40:24", '
'"EXIF:OffsetTimeOriginal": "-04:00", '
'"EXIF:ModifyDate": "2019:07:27 17:33:28"}]'
)
EXIF_JSON_EXPECTED = """
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding"],
"IPTC:Keywords": ["wedding"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2019:04:15",
"IPTC:DateCreated": "2019:04:15",
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
"""
EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED = """
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"EXIF:ImageDescription": "Bride Wedding day",
"XMP:Description": "Bride Wedding day",
"XMP:TagsList": ["wedding"],
"IPTC:Keywords": ["wedding"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2019:04:15",
"IPTC:DateCreated": "2019:04:15",
"EXIF:ModifyDate": "2019:04:15 14:40:24"}]
"""
def test_export_1():
@@ -489,6 +508,32 @@ def test_exiftool_json_sidecar():
assert json_got[k] == v
def test_exiftool_json_sidecar_ignore_date_modified():
import osxphotos
import json
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
photos = photosdb.photos(uuid=[EXIF_JSON_UUID])
json_expected = json.loads(EXIF_JSON_EXPECTED_IGNORE_DATE_MODIFIED)[0]
json_got = photos[0]._exiftool_json_sidecar(ignore_date_modified=True)
json_got = json.loads(json_got)[0]
# 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_keyword_template_long(caplog):
import osxphotos
from osxphotos._constants import _MAX_IPTC_KEYWORD_LEN
@@ -507,7 +552,10 @@ def test_exiftool_json_sidecar_keyword_template_long(caplog):
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2019:04:15",
"IPTC:DateCreated": "2019:04:15",
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
"""
)[0]
@@ -552,9 +600,12 @@ def test_exiftool_json_sidecar_keyword_template():
"XMP:TagsList": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"IPTC:Keywords": ["wedding", "Folder1/SubFolder2/AlbumInFolder", "I have a deleted twin"],
"XMP:PersonInImage": ["Maria"],
"XMP:Subject": ["wedding", "Maria"],
"XMP:Subject": ["wedding", "Maria"],
"EXIF:DateTimeOriginal": "2019:04:15 14:40:24",
"EXIF:CreateDate": "2019:04:15 14:40:24",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2019:04:15",
"IPTC:DateCreated": "2019:04:15",
"EXIF:ModifyDate": "2019:07:27 17:33:28"}]
"""
)[0]
@@ -609,11 +660,15 @@ def test_exiftool_json_sidecar_use_persons_keyword():
"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"],
"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:CreateDate": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2018:09:28",
"IPTC:DateCreated": "2018:09:28",
"EXIF:ModifyDate": "2018:09:28 15:35:49"}]
"""
)[0]
@@ -652,7 +707,11 @@ def test_exiftool_json_sidecar_use_albums_keyword():
"XMP:PersonInImage": ["Suzy", "Katie"],
"XMP:Subject": ["Kids", "Suzy", "Katie"],
"EXIF:DateTimeOriginal": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00"}]
"EXIF:CreateDate": "2018:09:28 15:35:49",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2018:09:28",
"IPTC:DateCreated": "2018:09:28",
"EXIF:ModifyDate": "2018:09:28 15:35:49"}]
"""
)[0]

View File

@@ -45,17 +45,23 @@ UUID_DICT = {
"xmp": "8SOE9s0XQVGsuq4ONohTng",
}
EXIF_JSON_EXPECTED = (
'[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", '
'"XMP:Title": "St. James\'s Park", "XMP:TagsList": ["UK", "England", '
'"London", "United Kingdom", "London 2018", "St. James\'s Park"], '
'"IPTC:Keywords": ["UK", "England", "London", "United Kingdom", "London 2018", '
'"St. James\'s Park"], "XMP:Subject": ["UK", "England", "London", "United Kingdom", '
'"London 2018", "St. James\'s Park"], "EXIF:GPSLatitude": 51.50357167, '
'"EXIF:GPSLongitude": -0.1318055, "EXIF:GPSLatitudeRef": "N", '
'"EXIF:GPSLongitudeRef": "W", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", '
'"EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:12:01 11:43:45"}]'
)
EXIF_JSON_EXPECTED = """
[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos",
"XMP:Title": "St. James\'s Park",
"XMP:TagsList": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
"IPTC:Keywords": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
"XMP:Subject": ["UK", "England", "London", "United Kingdom", "London 2018", "St. James\'s Park"],
"EXIF:GPSLatitude": 51.50357167,
"EXIF:GPSLongitude": -0.1318055,
"EXIF:GPSLatitudeRef": "N",
"EXIF:GPSLongitudeRef": "W",
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
"EXIF:CreateDate": "2018:10:13 09:18:12",
"EXIF:OffsetTimeOriginal": "-04:00",
"IPTC:DigitalCreationDate": "2018:10:13",
"IPTC:DateCreated": "2018:10:13",
"EXIF:ModifyDate": "2019:12:01 11:43:45"}]
"""
def test_export_1():

View File

@@ -1,6 +1,10 @@
""" Test basic methods for Mojave 10.14.6 """
import datetime
from collections import namedtuple
import pytest
from collections import namedtuple
from osxphotos._constants import _UNKNOWN_PERSON
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
@@ -535,6 +539,31 @@ def test_date_modified_invalid(photosdb):
assert p.date_modified is None
def test_date_modified(photosdb):
""" Test date modified for photo that has been edited """
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
p = photos[0]
assert p.date_modified == datetime.datetime(
2019,
11,
27,
1,
30,
16,
681150,
tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=72000)),
)
def test_date_modified_none(photosdb):
""" Test date modified for a photo that hasn't been edited """
photos = photosdb.photos(uuid=[UUID_DICT["no_adjustments"]])
p = photos[0]
assert p.date_modified is None
def test_uti(photosdb):
for uuid, utis in UUID_UTI_DICT.items():
photo = photosdb.get_photo(uuid)