Compare commits

..

35 Commits

Author SHA1 Message Date
Rhet Turnbull
25eacc7cad Added --missing to export, see issue #277 2020-11-29 15:30:45 -08:00
Rhet Turnbull
d9dcf0917a Catch errors in export_photo 2020-11-28 20:00:10 -08:00
Rhet Turnbull
4f36c7c948 Updated CHANGELOG.md 2020-11-28 09:27:12 -08:00
Rhet Turnbull
d22eaf39ed Added --report option to CLI, implements #253 2020-11-28 09:24:16 -08:00
Rhet Turnbull
adf2ba7678 Updated CHANGELOG.md 2020-11-27 17:00:53 -08:00
Rhet Turnbull
af827d7a57 Updated template values 2020-11-27 16:58:11 -08:00
Rhet Turnbull
48acb42631 Added {exiftool} template, implements issue #259 2020-11-27 16:43:48 -08:00
Rhet Turnbull
eba661acf7 Updated CHANGELOG.md 2020-11-26 19:53:35 -08:00
Rhet Turnbull
399d432a66 Added --original-suffix for issue #263 2020-11-26 18:36:17 -08:00
Rhet Turnbull
4cebc57d60 Updated CHANGELOG.md 2020-11-26 15:26:54 -08:00
Rhet Turnbull
489fea56e9 Added tests for issue #265 2020-11-26 13:21:40 -08:00
Rhet Turnbull
0632a97f55 Simplified sidecar table in export_db 2020-11-26 10:42:10 -08:00
Rhet Turnbull
d5a9f76719 More work on issue #265 2020-11-26 10:15:09 -08:00
Rhet Turnbull
382fca3f92 Initial implementation for issue #265 2020-11-26 09:08:26 -08:00
Rhet Turnbull
a807894095 Removed debug code from _photoinfo_export.py 2020-11-25 21:42:27 -08:00
Rhet Turnbull
559350f71d Updated CHANGELOG.md 2020-11-25 20:55:15 -08:00
Rhet Turnbull
b5195f9d2b version bump 2020-11-25 20:32:36 -08:00
Rhet Turnbull
fa332186ab Fix for missing original_filename, issue #267 2020-11-25 20:31:19 -08:00
Rhet Turnbull
aa2ebf55bb Updated test 2020-11-25 19:04:36 -08:00
Rhet Turnbull
d1fbb9fe86 Updated CHANGELOG.md 2020-11-25 18:58:48 -08:00
Rhet Turnbull
116cb662fb Added test for missing original_filename 2020-11-25 18:32:12 -08:00
Rhet Turnbull
db68defc44 version bump 2020-11-25 17:55:07 -08:00
Rhet Turnbull
7460bc88fc Add @jstrine as a contributor 2020-11-25 17:54:53 -08:00
Rhet Turnbull
dbbbbf10a8 Merge pull request #272 from jstrine/fix_xml_escaping
Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix!
2020-11-25 17:52:22 -08:00
Rhet Turnbull
0633814ab2 Merge pull request #270 from jstrine/fix_gps_xmp
Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix!
2020-11-25 17:51:57 -08:00
Rhet Turnbull
df7d45659a Merge pull request #268 from jstrine/fix_path_none
Continue even if the original filename is None, thanks to @jstrine for the fix!
2020-11-25 17:51:38 -08:00
Jonathan Strine
cec266bba4 Fix tests again
Third times the charm to fix a find-replace error this time.
2020-11-25 19:51:09 -05:00
Jonathan Strine
d0d2e80800 Fix tests for apostrophe
Previous commit didn't reflect all locations and had a copy/paste error.
2020-11-25 19:45:21 -05:00
Jonathan Strine
aafdbea564 Fix test to accomodate for escaped apostrophe 2020-11-25 19:36:09 -05:00
Jonathan Strine
c42050a10c Escape characters which cause XML parsing issues 2020-11-25 19:31:51 -05:00
Jonathan Strine
c27cfb1223 Fix test for XMP sidecar with GPS info 2020-11-25 19:24:56 -05:00
Jonathan Strine
ad144da8a0 Use GPSCoordinate format for geolocation 2020-11-25 18:09:38 -05:00
Jonathan Strine
5352aec3b9 Continue even if the original filename is None
Some photos seemed to be missing the original_filename (returning None).
This fix prevents the traceback.
2020-11-25 17:00:22 -05:00
Rhet Turnbull
e951e5361e Exposed --use-photos-export and --use-photokit 2020-11-25 09:15:16 -08:00
Rhet Turnbull
f7bd1376e1 Updated CHANGELOG.md 2020-11-24 06:50:52 -08:00
16 changed files with 1490 additions and 381 deletions

View File

@@ -100,6 +100,15 @@
"contributions": [ "contributions": [
"code" "code"
] ]
},
{
"login": "jstrine",
"name": "Jonathan Strine",
"avatar_url": "https://avatars1.githubusercontent.com/u/33943447?v=4",
"profile": "https://github.com/jstrine",
"contributions": [
"code"
]
} }
], ],
"contributorsPerLine": 7 "contributorsPerLine": 7

View File

@@ -4,6 +4,80 @@ 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). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.37.1](https://github.com/RhetTbull/osxphotos/compare/v0.37.0...v0.37.1)
> 28 November 2020
- Added --report option to CLI, implements #253 [`d22eaf3`](https://github.com/RhetTbull/osxphotos/commit/d22eaf39edc8b0b489b011d6d21345dcedcc8dff)
- Updated template values [`af827d7`](https://github.com/RhetTbull/osxphotos/commit/af827d7a5769f41579d300a7cc511251d86b7eed)
#### [v0.37.0](https://github.com/RhetTbull/osxphotos/compare/v0.36.25...v0.37.0)
> 28 November 2020
- Added {exiftool} template, implements issue #259 [`48acb42`](https://github.com/RhetTbull/osxphotos/commit/48acb42631226a71bfc636eea2d3151f1b7165f4)
#### [v0.36.25](https://github.com/RhetTbull/osxphotos/compare/v0.36.24...v0.36.25)
> 27 November 2020
- Added --original-suffix for issue #263 [`399d432`](https://github.com/RhetTbull/osxphotos/commit/399d432a66354b9c235f30d10c6985fbde1b7e4f)
#### [v0.36.24](https://github.com/RhetTbull/osxphotos/compare/v0.36.23...v0.36.24)
> 26 November 2020
- Initial implementation for issue #265 [`382fca3`](https://github.com/RhetTbull/osxphotos/commit/382fca3f92a3c251c12426dd0dc6d7dc21b691cf)
- More work on issue #265 [`d5a9f76`](https://github.com/RhetTbull/osxphotos/commit/d5a9f767199d25ebd9d5925d05ee39ea7e51ac26)
- Simplified sidecar table in export_db [`0632a97`](https://github.com/RhetTbull/osxphotos/commit/0632a97f55af67c7e5265b0d3283155c7c087e89)
#### [v0.36.23](https://github.com/RhetTbull/osxphotos/compare/v0.36.22...v0.36.23)
> 26 November 2020
- Fix for missing original_filename, issue #267 [`fa33218`](https://github.com/RhetTbull/osxphotos/commit/fa332186ab3cdbe1bfd6496ff29b652ef984a5f8)
- version bump [`b5195f9`](https://github.com/RhetTbull/osxphotos/commit/b5195f9d2b81cf6737b65e3cd3793ea9b0da13eb)
- Updated test [`aa2ebf5`](https://github.com/RhetTbull/osxphotos/commit/aa2ebf55bb50eec14f86a532334b376e407f4bbc)
#### [v0.36.22](https://github.com/RhetTbull/osxphotos/compare/v0.36.21...v0.36.22)
> 26 November 2020
- Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix! [`#272`](https://github.com/RhetTbull/osxphotos/pull/272)
- Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix! [`#270`](https://github.com/RhetTbull/osxphotos/pull/270)
- Continue even if the original filename is None, thanks to @jstrine for the fix! [`#268`](https://github.com/RhetTbull/osxphotos/pull/268)
- Added test for missing original_filename [`116cb66`](https://github.com/RhetTbull/osxphotos/commit/116cb662fbddf9153f6858c6ea97dc7f65c77705)
- Add @jstrine as a contributor [`7460bc8`](https://github.com/RhetTbull/osxphotos/commit/7460bc88fcc5e1e7435c9b9bcdf7ec9c7c5e39ea)
- Escape characters which cause XML parsing issues [`c42050a`](https://github.com/RhetTbull/osxphotos/commit/c42050a10cac40b0b5ac70c587e07f257a9b50dd)
- Fix tests for apostrophe [`d0d2e80`](https://github.com/RhetTbull/osxphotos/commit/d0d2e8080096bf66f93a830386800ce713680c51)
- Fix test for XMP sidecar with GPS info [`c27cfb1`](https://github.com/RhetTbull/osxphotos/commit/c27cfb1223fa82b9e5549b93c283e9444693270a)
#### [v0.36.21](https://github.com/RhetTbull/osxphotos/compare/v0.36.20...v0.36.21)
> 25 November 2020
- Exposed --use-photos-export and --use-photokit [`e951e53`](https://github.com/RhetTbull/osxphotos/commit/e951e5361e59060229787bb1ea3fc4e088ffff99)
#### [v0.36.20](https://github.com/RhetTbull/osxphotos/compare/v0.36.19...v0.36.20)
> 23 November 2020
- Added photokit export as hidden --use-photokit option [`26f96d5`](https://github.com/RhetTbull/osxphotos/commit/26f96d582c01ce9816b1f54f0e74c8570f133f7c)
#### [v0.36.19](https://github.com/RhetTbull/osxphotos/compare/v0.36.18...v0.36.19)
> 19 November 2020
- Removed debug statement in _photoinfo_export [`8cb15d1`](https://github.com/RhetTbull/osxphotos/commit/8cb15d15551094dcaf1b0ef32d6ac0273be7fd37)
#### [v0.36.18](https://github.com/RhetTbull/osxphotos/compare/v0.36.17...v0.36.18)
> 14 November 2020
- Moved AppleScript to photoscript [`3c85f26`](https://github.com/RhetTbull/osxphotos/commit/3c85f26f901645ce297685ccd639792757fbc995)
- Fixed missing data file for photoscript [`2d9429c`](https://github.com/RhetTbull/osxphotos/commit/2d9429c8eefabe6233fc580f65511c48ee6c01e5)
- Version bump, updated requirements [`3b6dd08`](https://github.com/RhetTbull/osxphotos/commit/3b6dd08d2bb2b20a55064bf24fe7ce788e7268ef)
#### [v0.36.17](https://github.com/RhetTbull/osxphotos/compare/v0.36.15...v0.36.17) #### [v0.36.17](https://github.com/RhetTbull/osxphotos/compare/v0.36.15...v0.36.17)
> 12 November 2020 > 12 November 2020

View File

@@ -3,7 +3,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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)](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-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](https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
- [OSXPhotos](#osxphotos) - [OSXPhotos](#osxphotos)
@@ -227,6 +227,9 @@ Options:
--no-comment Search for photos with no comments. --no-comment Search for photos with no comments.
--has-likes Search for photos that have likes. --has-likes Search for photos that have likes.
--no-likes Search for photos with no likes. --no-likes Search for photos with no likes.
--missing Export only photos missing from the Photos
library; must be used with --download-
missing.
--deleted Include photos from the 'Recently Deleted' --deleted Include photos from the 'Recently Deleted'
folder. folder.
--deleted-only Include only photos from the 'Recently --deleted-only Include only photos from the 'Recently
@@ -234,7 +237,8 @@ Options:
--update Only export new or updated files. See notes --update Only export new or updated files. See notes
below on export and --update. below on export and --update.
--dry-run Dry run (test) the export but don't actually --dry-run Dry run (test) the export but don't actually
export any files; most useful with --verbose export any files; most useful with
--verbose.
--export-as-hardlink Hardlink files instead of copying them. --export-as-hardlink Hardlink files instead of copying them.
Cannot be used with --exiftool which creates Cannot be used with --exiftool which creates
copies of the files with embedded EXIF data. copies of the files with embedded EXIF data.
@@ -351,11 +355,30 @@ Options:
photo would be named photo would be named
'photoname_bearbeiten.ext'. The default 'photoname_bearbeiten.ext'. The default
suffix is '_edited'. suffix is '_edited'.
--original-suffix SUFFIX Optional suffix for naming original photos.
Default name for original photos is in form
'filename.ext'. For example, with '--
original-suffix _original', the original
photo would be named
'filename_original.ext'. The default suffix
is '' (no suffix).
--no-extended-attributes Don't copy extended attributes when --no-extended-attributes Don't copy extended attributes when
exporting. You only need this if exporting exporting. You only need this if exporting
to a filesystem that doesn't support Mac OS to a filesystem that doesn't support Mac OS
extended attributes. Only use this if you extended attributes. Only use this if you
get an error while exporting. get an error while exporting.
--use-photos-export Force the use of AppleScript or PhotoKit to
export even if not missing (see also '--
download-missing' and '--use-photokit').
--use-photokit Use with '--download-missing' or '--use-
photos-export' to use direct Photos
interface instead of AppleScript to export.
Highly experimental alpha feature; does not
work with iTerm2 (use with Terminal.app).
This is faster and more reliable than the
default AppleScript interface.
--report REPORTNAME.CSV Write a CSV formatted report of all files
that were exported.
-h, --help Show this message and exit. -h, --help Show this message and exit.
** Export ** ** Export **
@@ -619,18 +642,26 @@ exported, one to each directory. For example: --directory
of the following directories if the photos were created in 2019 and were in of the following directories if the photos were created in 2019 and were in
albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family
Substitution Description Substitution Description
{album} Album(s) photo is contained in {album} Album(s) photo is contained in
{folder_album} Folder path + album photo is contained in. e.g. {folder_album} Folder path + album photo is contained in. e.g.
'Folder/Subfolder/Album' or just 'Album' if no enclosing 'Folder/Subfolder/Album' or just 'Album' if no
folder enclosing folder
{keyword} Keyword(s) assigned to photo {keyword} Keyword(s) assigned to photo
{person} Person(s) / face(s) in a photo {person} Person(s) / face(s) in a photo
{label} Image categorization label associated with a photo {label} Image categorization label associated with a photo
(Photos 5 only) (Photos 5 only)
{label_normalized} All lower case version of 'label' (Photos 5 only) {label_normalized} All lower case version of 'label' (Photos 5 only)
{comment} Comment(s) on shared Photos; format is 'Person name: {comment} Comment(s) on shared Photos; format is 'Person
comment text' (Photos 5 only) name: comment text' (Photos 5 only)
{exiftool:GROUP:TAGNAME} Use exiftool (https://exiftool.org) to extract
metadata, in form GROUP:TAGNAME, from image. E.g.
'{exiftool:EXIF:Make}' to get camera make, or
{exiftool:IPTC:Keywords} to extract keywords. See
https://exiftool.org/TagNames/ for list of valid
tag names. You must specify group (e.g. EXIF,
IPTC, etc) as used in `exiftool -G`. exiftool must
be installed in the path to use this template.
``` ```
Example: export all photos to ~/Desktop/export group in folders by date created Example: export all photos to ~/Desktop/export group in folders by date created
@@ -2028,6 +2059,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|{label}|Image categorization label associated with a photo (Photos 5 only)| |{label}|Image categorization label associated with a photo (Photos 5 only)|
|{label_normalized}|All lower case version of 'label' (Photos 5 only)| |{label_normalized}|All lower case version of 'label' (Photos 5 only)|
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)| |{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)|
|{exiftool:GROUP:TAGNAME}|Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`. exiftool must be installed in the path to use this template.|
### Utility Functions ### Utility Functions
@@ -2149,6 +2181,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<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/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/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> <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>
<td align="center"><a href="https://github.com/jstrine"><img src="https://avatars1.githubusercontent.com/u/33943447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jonathan Strine</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=jstrine" title="Code">💻</a></td>
</tr> </tr>
</table> </table>

View File

@@ -28,6 +28,7 @@ from .export_db import ExportDB, ExportDBInMemory
from .fileutil import FileUtil, FileUtilNoOp from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults from .photoinfo import ExportResults
from .photokit import check_photokit_authorization, request_photokit_authorization
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
# global variable to control verbose output # global variable to control verbose output
@@ -97,7 +98,7 @@ class DateTimeISO8601(click.ParamType):
def convert(self, value, param, ctx): def convert(self, value, param, ctx):
try: try:
return datetime.datetime.fromisoformat(value) return datetime.datetime.fromisoformat(value)
except: except Exception:
self.fail( self.fail(
f"Invalid value for --{param.name}: invalid datetime format {value}. " f"Invalid value for --{param.name}: invalid datetime format {value}. "
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]" "Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
@@ -650,7 +651,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose_):
val = getattr(photosdb, attr) val = getattr(photosdb, attr)
print(f"{attr}:") print(f"{attr}:")
pprint.pprint(val) pprint.pprint(val)
except: except Exception:
print(f"Did not find attribute {attr} in PhotosDB") print(f"Did not find attribute {attr} in PhotosDB")
@@ -845,12 +846,12 @@ def places(ctx, cli_obj, db, json_, photos_library):
if photo.place: if photo.place:
try: try:
place_names[photo.place.name] += 1 place_names[photo.place.name] += 1
except: except Exception:
place_names[photo.place.name] = 1 place_names[photo.place.name] = 1
else: else:
try: try:
place_names[_UNKNOWN_PLACE] += 1 place_names[_UNKNOWN_PLACE] += 1
except: except Exception:
place_names[_UNKNOWN_PLACE] = 1 place_names[_UNKNOWN_PLACE] = 1
# sort by place count # sort by place count
@@ -1198,6 +1199,11 @@ def query(
@DB_OPTION @DB_OPTION
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.") @click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.")
@query_options @query_options
@click.option(
"--missing",
is_flag=True,
help="Export only photos missing from the Photos library; must be used with --download-missing.",
)
@deleted_options @deleted_options
@click.option( @click.option(
"--update", "--update",
@@ -1207,7 +1213,7 @@ def query(
@click.option( @click.option(
"--dry-run", "--dry-run",
is_flag=True, is_flag=True,
help="Dry run (test) the export but don't actually export any files; most useful with --verbose", help="Dry run (test) the export but don't actually export any files; most useful with --verbose.",
) )
@click.option( @click.option(
"--export-as-hardlink", "--export-as-hardlink",
@@ -1382,6 +1388,14 @@ def query(
"'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo " "'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo "
"would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'.", "would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'.",
) )
@click.option(
"--original-suffix",
metavar="SUFFIX",
default="",
help="Optional suffix for naming original photos. Default name for original photos is in form "
"'filename.ext'. For example, with '--original-suffix _original', the original photo "
"would be named 'filename_original.ext'. The default suffix is '' (no suffix).",
)
@click.option( @click.option(
"--no-extended-attributes", "--no-extended-attributes",
is_flag=True, is_flag=True,
@@ -1394,15 +1408,21 @@ def query(
"--use-photos-export", "--use-photos-export",
is_flag=True, is_flag=True,
default=False, default=False,
hidden=True, help="Force the use of AppleScript or PhotoKit to export even if not missing (see also '--download-missing' and '--use-photokit').",
help="Force the use of AppleScript to export even if not missing (see also --download-missing).",
) )
@click.option( @click.option(
"--use-photokit", "--use-photokit",
is_flag=True, is_flag=True,
default=False, default=False,
hidden=True, help="Use with '--download-missing' or '--use-photos-export' to use direct Photos interface instead of AppleScript to export. "
help="Use PhotoKit interface instead of AppleScript to export. Highly experimental alpha feature.", "Highly experimental alpha feature; does not work with iTerm2 (use with Terminal.app). "
"This is faster and more reliable than the default AppleScript interface.",
)
@click.option(
"--report",
metavar="REPORTNAME.CSV",
help="Write a CSV formatted report of all files that were exported.",
type=click.Path(),
) )
@DB_ARGUMENT @DB_ARGUMENT
@click.argument("dest", nargs=1, type=click.Path(exists=True)) @click.argument("dest", nargs=1, type=click.Path(exists=True))
@@ -1436,6 +1456,7 @@ def export(
from_date, from_date,
to_date, to_date,
verbose_, verbose_,
missing,
update, update,
dry_run, dry_run,
export_as_hardlink, export_as_hardlink,
@@ -1483,6 +1504,7 @@ def export(
directory, directory,
filename_template, filename_template,
edited_suffix, edited_suffix,
original_suffix,
place, place,
no_place, no_place,
has_comment, has_comment,
@@ -1495,6 +1517,7 @@ def export(
deleted_only, deleted_only,
use_photos_export, use_photos_export,
use_photokit, use_photokit,
report,
): ):
""" Export photos from the Photos database. """ Export photos from the Photos database.
Export path DEST is required. Export path DEST is required.
@@ -1512,7 +1535,12 @@ def export(
VERBOSE = bool(verbose_) VERBOSE = bool(verbose_)
if not os.path.isdir(dest): if not os.path.isdir(dest):
sys.exit(f"DEST {dest} must be valid path") click.echo(f"DEST {dest} must be valid path", err=True)
raise click.Abort()
if report and os.path.isdir(report):
click.echo(f"report is a directory, must be file name", err=True)
raise click.Abort()
# sanity check input args # sanity check input args
exclusive = [ exclusive = [
@@ -1545,6 +1573,32 @@ def export(
click.echo(cli.commands["export"].get_help(ctx), err=True) click.echo(cli.commands["export"].get_help(ctx), err=True)
return return
if export_as_hardlink and download_missing:
click.echo(
"Incompatible export options: --export-as-hardlink is not compatible with --download-missing",
err=True,
)
raise click.Abort()
if missing and not download_missing:
click.echo(
"Incompatible export options: --missing must be used with --download-missing",
err=True,
)
raise click.Abort()
if use_photokit and not check_photokit_authorization():
click.echo(
"Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access."
)
request_photokit_authorization()
click.confirm("Have you granted access?")
if not check_photokit_authorization():
click.echo(
"Failed to get access to the Photos library which is needed with `--use-photokit`."
)
return
# initialize export flags # initialize export flags
# by default, will export all versions of photos unless skip flag is set # by default, will export all versions of photos unless skip flag is set
(export_edited, export_bursts, export_live, export_raw) = [ (export_edited, export_bursts, export_live, export_raw) = [
@@ -1640,7 +1694,7 @@ def export(
not_favorite=not_favorite, not_favorite=not_favorite,
hidden=hidden, hidden=hidden,
not_hidden=not_hidden, not_hidden=not_hidden,
missing=None, # missing -- won't export these but will warn user missing=missing,
not_missing=None, not_missing=None,
shared=shared, shared=shared,
not_shared=not_shared, not_shared=not_shared,
@@ -1707,6 +1761,11 @@ def export(
results_skipped = [] results_skipped = []
results_exif_updated = [] results_exif_updated = []
results_touched = [] results_touched = []
results_converted = []
results_sidecar_json_written = []
results_sidecar_json_skipped = []
results_sidecar_xmp_written = []
results_sidecar_xmp_skipped = []
if verbose_: if verbose_:
for p in photos: for p in photos:
results = export_photo( results = export_photo(
@@ -1737,6 +1796,7 @@ def export(
dry_run=dry_run, dry_run=dry_run,
touch_file=touch_file, touch_file=touch_file,
edited_suffix=edited_suffix, edited_suffix=edited_suffix,
original_suffix=original_suffix,
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
@@ -1749,6 +1809,11 @@ def export(
results_skipped.extend(results.skipped) results_skipped.extend(results.skipped)
results_exif_updated.extend(results.exif_updated) results_exif_updated.extend(results.exif_updated)
results_touched.extend(results.touched) results_touched.extend(results.touched)
results_converted.extend(results.converted_to_jpeg)
results_sidecar_json_written.extend(results.sidecar_json_written)
results_sidecar_json_skipped.extend(results.sidecar_json_skipped)
results_sidecar_xmp_written.extend(results.sidecar_xmp_written)
results_sidecar_xmp_skipped.extend(results.sidecar_xmp_skipped)
# if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg": # if convert_to_jpeg and p.isphoto and p.uti != "public.jpeg":
# for photo_file in set( # for photo_file in set(
@@ -1788,6 +1853,7 @@ def export(
dry_run=dry_run, dry_run=dry_run,
touch_file=touch_file, touch_file=touch_file,
edited_suffix=edited_suffix, edited_suffix=edited_suffix,
original_suffix=original_suffix,
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
convert_to_jpeg=convert_to_jpeg, convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
@@ -1800,9 +1866,41 @@ def export(
results_skipped.extend(results.skipped) results_skipped.extend(results.skipped)
results_exif_updated.extend(results.exif_updated) results_exif_updated.extend(results.exif_updated)
results_touched.extend(results.touched) results_touched.extend(results.touched)
results_converted.extend(results.converted_to_jpeg)
results_sidecar_json_written.extend(results.sidecar_json_written)
results_sidecar_json_skipped.extend(results.sidecar_json_skipped)
results_sidecar_xmp_written.extend(results.sidecar_xmp_written)
results_sidecar_xmp_skipped.extend(results.sidecar_xmp_skipped)
stop_time = time.perf_counter() stop_time = time.perf_counter()
# print summary results # print summary results
# print(f"results_exported: {results_exported}")
# print(f"results_new: {results_new}")
# print(f"results_updated: {results_updated}")
# print(f"results_skipped: {results_skipped}")
# print(f"results_exif_updated: {results_exif_updated}")
# print(f"results_touched: {results_touched}")
# print(f"results_converted: {results_converted}")
# print(f"results_sidecar_json: {results_sidecar_json}")
# print(f"results_sidecar_xmp: {results_sidecar_xmp}")
if report:
verbose(f"Writing export report to {report}")
write_export_report(
report,
results_exported=results_exported,
results_new=results_new,
results_updated=results_updated,
results_skipped=results_skipped,
results_exif_updated=results_exif_updated,
results_touched=results_touched,
results_converted=results_converted,
results_sidecar_json_written=results_sidecar_json_written,
results_sidecar_json_skipped=results_sidecar_json_skipped,
results_sidecar_xmp_written=results_sidecar_xmp_written,
results_sidecar_xmp_skipped=results_sidecar_xmp_skipped,
)
if update: if update:
photo_str_new = "photos" if len(results_new) != 1 else "photo" photo_str_new = "photos" if len(results_new) != 1 else "photo"
photo_str_updated = "photos" if len(results_updated) != 1 else "photo" photo_str_updated = "photos" if len(results_updated) != 1 else "photo"
@@ -2296,6 +2394,7 @@ def export_photo(
dry_run=None, dry_run=None,
touch_file=None, touch_file=None,
edited_suffix="_edited", edited_suffix="_edited",
original_suffix="",
use_photos_export=False, use_photos_export=False,
convert_to_jpeg=False, convert_to_jpeg=False,
jpeg_quality=1.0, jpeg_quality=1.0,
@@ -2351,19 +2450,19 @@ def export_photo(
if photo.ismissing: if photo.ismissing:
space = " " if not verbose_ else "" space = " " if not verbose_ else ""
verbose(f"{space}Skipping missing photo {photo.original_filename}") verbose(f"{space}Skipping missing photo {photo.original_filename}")
return ExportResults([], [], [], [], [], []) return ExportResults([], [], [], [], [], [], [], [], [], [], [])
elif photo.path is None: elif photo.path is None:
space = " " if not verbose_ else "" space = " " if not verbose_ else ""
verbose( verbose(
f"{space}WARNING: photo {photo.original_filename} ({photo.uuid}) is missing but ismissing=False, " f"{space}WARNING: photo {photo.original_filename} ({photo.uuid}) is missing but ismissing=False, "
f"skipping {photo.original_filename}" f"skipping {photo.original_filename}"
) )
return ExportResults([], [], [], [], [], []) return ExportResults([], [], [], [], [], [], [], [], [], [], [])
elif photo.ismissing and not photo.iscloudasset and not photo.incloud: elif photo.ismissing and not photo.iscloudasset and not photo.incloud:
verbose( verbose(
f"Skipping missing {photo.original_filename}: not iCloud asset or missing from cloud" f"Skipping missing {photo.original_filename}: not iCloud asset or missing from cloud"
) )
return ExportResults([], [], [], [], [], []) return ExportResults([], [], [], [], [], [], [], [], [], [], [])
results_exported = [] results_exported = []
results_new = [] results_new = []
@@ -2371,6 +2470,11 @@ def export_photo(
results_skipped = [] results_skipped = []
results_exif_updated = [] results_exif_updated = []
results_touched = [] results_touched = []
results_converted = []
results_sidecar_json_written = []
results_sidecar_json_skipped = []
results_sidecar_xmp_written = []
results_sidecar_xmp_skipped = []
export_original = not (skip_original_if_edited and photo.hasadjustments) export_original = not (skip_original_if_edited and photo.hasadjustments)
@@ -2392,7 +2496,19 @@ def export_photo(
filenames = get_filenames_from_template(photo, filename_template, original_name) filenames = get_filenames_from_template(photo, filename_template, original_name)
for filename in filenames: for filename in filenames:
verbose(f"Exporting {photo.original_filename} ({photo.filename}) as {filename}") if original_suffix:
original_filename = pathlib.Path(filename)
original_filename = (
original_filename.parent
/ f"{original_filename.stem}{original_suffix}{original_filename.suffix}"
)
original_filename = str(original_filename)
else:
original_filename = filename
verbose(
f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename}"
)
dest_paths = get_dirnames_from_template( dest_paths = get_dirnames_from_template(
photo, directory, export_by_date, dest, dry_run photo, directory, export_by_date, dest, dry_run
@@ -2418,81 +2534,17 @@ def export_photo(
# export the photo to each path in dest_paths # export the photo to each path in dest_paths
for dest_path in dest_paths: for dest_path in dest_paths:
if not export_original: if export_original:
verbose(f"Skipping original version of {photo.original_filename}") try:
else: export_results = photo.export2(
export_results = photo.export2(
dest_path,
filename,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
live_photo=export_live,
raw_photo=export_raw,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
use_photos_export=use_photos_export,
exiftool=exiftool,
no_xattr=no_extended_attributes,
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
update=update,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
touch_file=touch_file,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
)
results_exported.extend(export_results.exported)
results_new.extend(export_results.new)
results_updated.extend(export_results.updated)
results_skipped.extend(export_results.skipped)
results_exif_updated.extend(export_results.exif_updated)
results_touched.extend(export_results.touched)
if verbose_:
for exported in export_results.exported:
verbose(f"Exported {exported}")
for new in export_results.new:
verbose(f"Exported new file {new}")
for updated in export_results.updated:
verbose(f"Exported updated file {updated}")
for skipped in export_results.skipped:
verbose(f"Skipped up to date file {skipped}")
for touched in export_results.touched:
verbose(f"Touched date on file {touched}")
# if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments:
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
if not download_missing and photo.path_edited is None:
verbose(f"Skipping missing edited photo for {filename}")
else:
edited_name = pathlib.Path(filename)
# check for correct edited suffix
if photo.path_edited is not None:
edited_ext = pathlib.Path(photo.path_edited).suffix
else:
# use filename suffix which might be wrong,
# will be corrected by use_photos_export
edited_ext = pathlib.Path(photo.filename).suffix
edited_name = f"{edited_name.stem}{edited_suffix}{edited_ext}"
verbose(f"Exporting edited version of {filename} as {edited_name}")
export_results_edited = photo.export2(
dest_path, dest_path,
edited_name, original_filename,
sidecar_json=sidecar_json, sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp, sidecar_xmp=sidecar_xmp,
live_photo=export_live,
raw_photo=export_raw,
export_as_hardlink=export_as_hardlink, export_as_hardlink=export_as_hardlink,
overwrite=overwrite, overwrite=overwrite,
edited=True,
use_photos_export=use_photos_export, use_photos_export=use_photos_export,
exiftool=exiftool, exiftool=exiftool,
no_xattr=no_extended_attributes, no_xattr=no_extended_attributes,
@@ -2509,26 +2561,136 @@ def export_photo(
jpeg_quality=jpeg_quality, jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit, use_photokit=use_photokit,
verbose=verbose,
) )
results_exported.extend(export_results_edited.exported) results_exported.extend(export_results.exported)
results_new.extend(export_results_edited.new) results_new.extend(export_results.new)
results_updated.extend(export_results_edited.updated) results_updated.extend(export_results.updated)
results_skipped.extend(export_results_edited.skipped) results_skipped.extend(export_results.skipped)
results_exif_updated.extend(export_results_edited.exif_updated) results_exif_updated.extend(export_results.exif_updated)
results_touched.extend(export_results_edited.touched) results_touched.extend(export_results.touched)
results_converted.extend(export_results.converted_to_jpeg)
results_sidecar_json_written.extend(
export_results.sidecar_json_written
)
results_sidecar_json_skipped.extend(
export_results.sidecar_json_skipped
)
results_sidecar_xmp_written.extend(
export_results.sidecar_xmp_written
)
results_sidecar_xmp_skipped.extend(
export_results.sidecar_xmp_skipped
)
if verbose_: if verbose_:
for exported in export_results_edited.exported: for exported in export_results.exported:
verbose(f"Exported {exported}") verbose(f"Exported {exported}")
for new in export_results_edited.new: for new in export_results.new:
verbose(f"Exported new file {new}") verbose(f"Exported new file {new}")
for updated in export_results_edited.updated: for updated in export_results.updated:
verbose(f"Exported updated file {updated}") verbose(f"Exported updated file {updated}")
for skipped in export_results_edited.skipped: for skipped in export_results.skipped:
verbose(f"Skipped up to date file {skipped}") verbose(f"Skipped up to date file {skipped}")
for touched in export_results_edited.touched: for touched in export_results.touched:
verbose(f"Touched date on file {touched}") verbose(f"Touched date on file {touched}")
except Exception:
click.echo(
f"Error exporting photo {photo.original_filename} ({photo.filename}) as {original_filename}",
err=True,
)
else:
verbose(f"Skipping original version of {photo.original_filename}")
# if export-edited, also export the edited version
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments:
# if download_missing and the photo is missing or path doesn't exist,
# try to download with Photos
if not download_missing and photo.path_edited is None:
verbose(f"Skipping missing edited photo for {filename}")
else:
edited_filename = pathlib.Path(filename)
# check for correct edited suffix
if photo.path_edited is not None:
edited_ext = pathlib.Path(photo.path_edited).suffix
else:
# use filename suffix which might be wrong,
# will be corrected by use_photos_export
edited_ext = pathlib.Path(photo.filename).suffix
edited_filename = (
f"{edited_filename.stem}{edited_suffix}{edited_ext}"
)
verbose(
f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}"
)
try:
export_results_edited = photo.export2(
dest_path,
edited_filename,
sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
export_as_hardlink=export_as_hardlink,
overwrite=overwrite,
edited=True,
use_photos_export=use_photos_export,
exiftool=exiftool,
no_xattr=no_extended_attributes,
use_albums_as_keywords=album_keyword,
use_persons_as_keywords=person_keyword,
keyword_template=keyword_template,
description_template=description_template,
update=update,
export_db=export_db,
fileutil=fileutil,
dry_run=dry_run,
touch_file=touch_file,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
ignore_date_modified=ignore_date_modified,
use_photokit=use_photokit,
verbose=verbose,
)
results_exported.extend(export_results_edited.exported)
results_new.extend(export_results_edited.new)
results_updated.extend(export_results_edited.updated)
results_skipped.extend(export_results_edited.skipped)
results_exif_updated.extend(export_results_edited.exif_updated)
results_touched.extend(export_results_edited.touched)
results_converted.extend(
export_results_edited.converted_to_jpeg
)
results_sidecar_json_written.extend(
export_results_edited.sidecar_json_written
)
results_sidecar_json_skipped.extend(
export_results_edited.sidecar_json_skipped
)
results_sidecar_xmp_written.extend(
export_results_edited.sidecar_xmp_written
)
results_sidecar_xmp_skipped.extend(
export_results_edited.sidecar_xmp_skipped
)
if verbose_:
for exported in export_results_edited.exported:
verbose(f"Exported {exported}")
for new in export_results_edited.new:
verbose(f"Exported new file {new}")
for updated in export_results_edited.updated:
verbose(f"Exported updated file {updated}")
for skipped in export_results_edited.skipped:
verbose(f"Skipped up to date file {skipped}")
for touched in export_results_edited.touched:
verbose(f"Touched date on file {touched}")
except Exception:
click.echo(
f"Error exporting photo {filename} as {edited_filename}",
err=True,
)
return ExportResults( return ExportResults(
results_exported, results_exported,
@@ -2537,6 +2699,11 @@ def export_photo(
results_skipped, results_skipped,
results_exif_updated, results_exif_updated,
results_touched, results_touched,
results_converted,
results_sidecar_json_written,
results_sidecar_json_skipped,
results_sidecar_xmp_written,
results_sidecar_xmp_skipped,
) )
@@ -2566,7 +2733,11 @@ def get_filenames_from_template(photo, filename_template, original_name):
) )
filenames = [f"{file_}{photo_ext}" for file_ in filenames] filenames = [f"{file_}{photo_ext}" for file_ in filenames]
else: else:
filenames = [photo.original_filename] if original_name else [photo.filename] filenames = (
[photo.original_filename]
if (original_name and (photo.original_filename is not None))
else [photo.filename]
)
filenames = [sanitize_filename(filename) for filename in filenames] filenames = [sanitize_filename(filename) for filename in filenames]
return filenames return filenames
@@ -2684,5 +2855,111 @@ def load_uuid_from_file(filename):
return uuid return uuid
def write_export_report(
report_file,
results_exported,
results_new,
results_updated,
results_skipped,
results_exif_updated,
results_touched,
results_converted,
results_sidecar_json_written,
results_sidecar_json_skipped,
results_sidecar_xmp_written,
results_sidecar_xmp_skipped,
):
""" write CSV report with results from export """
# Collect results for reporting
# TODO: pull this in a separate write_report function
all_results = {
result: {
"filename": result,
"exported": 0,
"new": 0,
"updated": 0,
"skipped": 0,
"exif_updated": 0,
"touched": 0,
"converted_to_jpeg": 0,
"sidecar_xmp": 0,
"sidecar_json": 0,
}
for result in results_exported
+ results_new
+ results_updated
+ results_skipped
+ results_exif_updated
+ results_touched
+ results_converted
+ results_sidecar_json_written
+ results_sidecar_json_skipped
+ results_sidecar_xmp_written
+ results_sidecar_xmp_skipped
}
for result in results_exported:
all_results[result]["exported"] = 1
for result in results_new:
all_results[result]["new"] = 1
for result in results_updated:
all_results[result]["updated"] = 1
for result in results_skipped:
all_results[result]["skipped"] = 1
for result in results_exif_updated:
all_results[result]["exif_updated"] = 1
for result in results_touched:
all_results[result]["touched"] = 1
for result in results_converted:
all_results[result]["converted_to_jpeg"] = 1
for result in results_sidecar_xmp_written:
all_results[result]["sidecar_xmp"] = 1
all_results[result]["exported"] = 1
for result in results_sidecar_xmp_skipped:
all_results[result]["sidecar_xmp"] = 1
all_results[result]["skipped"] = 1
for result in results_sidecar_json_written:
all_results[result]["sidecar_json"] = 1
all_results[result]["exported"] = 1
for result in results_sidecar_json_skipped:
all_results[result]["sidecar_json"] = 1
all_results[result]["skipped"] = 1
report_columns = [
"filename",
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_xmp",
"sidecar_json",
]
try:
with open(report_file, "w") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=report_columns)
writer.writeheader()
for data in [result for result in all_results.values()]:
writer.writerow(data)
except IOError:
click.echo("Could not open output file for writing", err=True)
raise click.Abort()
if __name__ == "__main__": if __name__ == "__main__":
cli() # pylint: disable=no-value-for-parameter cli() # pylint: disable=no-value-for-parameter

View File

@@ -1,4 +1,4 @@
""" version info """ """ version info """
__version__ = "0.36.20" __version__ = "0.37.2"

View File

@@ -14,7 +14,7 @@ from sqlite3 import Error
from ._version import __version__ from ._version import __version__
OSXPHOTOS_EXPORTDB_VERSION = "2.0" OSXPHOTOS_EXPORTDB_VERSION = "3.2"
class ExportDB_ABC(ABC): class ExportDB_ABC(ABC):
@@ -76,6 +76,14 @@ class ExportDB_ABC(ABC):
def set_exifdata_for_file(self, uuid, exifdata): def set_exifdata_for_file(self, uuid, exifdata):
pass pass
@abstractmethod
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
pass
@abstractmethod
def get_sidecar_for_file(self, filename):
pass
@abstractmethod @abstractmethod
def set_data( def set_data(
self, self,
@@ -141,6 +149,12 @@ class ExportDBNoOp(ExportDB_ABC):
def set_exifdata_for_file(self, uuid, exifdata): def set_exifdata_for_file(self, uuid, exifdata):
pass pass
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
pass
def get_sidecar_for_file(self, filename):
return None, (None, None, None)
def set_data( def set_data(
self, self,
filename, filename,
@@ -379,6 +393,48 @@ class ExportDB(ExportDB_ABC):
except Error as e: except Error as e:
logging.warning(e) logging.warning(e)
def get_sidecar_for_file(self, filename):
""" returns the sidecar data and signature for a file """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"SELECT sidecar_data, mode, size, mtime FROM sidecar WHERE filepath_normalized = ?",
(filename,),
)
results = c.fetchone()
if results:
sidecar_data = results[0]
sidecar_sig = (
results[1],
results[2],
int(results[3]) if results[3] is not None else None,
)
else:
sidecar_data = None
sidecar_sig = (None, None, None)
except Error as e:
logging.warning(e)
sidecar_data = None
sidecar_sig = (None, None, None)
return sidecar_data, sidecar_sig
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
""" sets the sidecar data and signature for a file """
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
"INSERT OR REPLACE INTO sidecar(filepath_normalized, sidecar_data, mode, size, mtime) VALUES (?, ?, ?, ?, ?);",
(filename, sidecar_data, *sidecar_sig),
)
conn.commit()
except Error as e:
logging.warning(e)
def set_data( def set_data(
self, self,
filename, filename,
@@ -479,13 +535,11 @@ class ExportDB(ExportDB_ABC):
if not os.path.isfile(dbfile): if not os.path.isfile(dbfile):
conn = self._get_db_connection(dbfile) conn = self._get_db_connection(dbfile)
if conn: if not conn:
self._create_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
self.version = OSXPHOTOS_EXPORTDB_VERSION
else:
raise Exception("Error getting connection to database {dbfile}") raise Exception("Error getting connection to database {dbfile}")
self._create_db_tables(conn)
self.was_created = True
self.was_upgraded = ()
else: else:
conn = self._get_db_connection(dbfile) conn = self._get_db_connection(dbfile)
self.was_created = False self.was_created = False
@@ -495,8 +549,7 @@ class ExportDB(ExportDB_ABC):
self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION) self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION)
else: else:
self.was_upgraded = () self.was_upgraded = ()
self.version = OSXPHOTOS_EXPORTDB_VERSION self.version = OSXPHOTOS_EXPORTDB_VERSION
return conn return conn
def _get_db_connection(self, dbfile): def _get_db_connection(self, dbfile):
@@ -570,11 +623,20 @@ class ExportDB(ExportDB_ABC):
size INTEGER, size INTEGER,
mtime REAL mtime REAL
); """, ); """,
"sql_sidecar_table": """ CREATE TABLE IF NOT EXISTS sidecar (
id INTEGER PRIMARY KEY,
filepath_normalized TEXT NOT NULL,
sidecar_data TEXT,
mode INTEGER,
size INTEGER,
mtime REAL
); """,
"sql_files_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """, "sql_files_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """,
"sql_info_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """, "sql_info_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """,
"sql_exifdata_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """, "sql_exifdata_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """,
"sql_edited_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""", "sql_edited_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""",
"sql_converted_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""", "sql_converted_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""",
"sql_sidecar_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""",
} }
try: try:
c = conn.cursor() c = conn.cursor()

View File

@@ -13,6 +13,7 @@
# TODO: should this be its own PhotoExporter class? # TODO: should this be its own PhotoExporter class?
import glob import glob
import hashlib
import json import json
import logging import logging
import os import os
@@ -41,14 +42,34 @@ from ..photokit import (
PhotoLibrary, PhotoLibrary,
PhotoKitFetchFailed, PhotoKitFetchFailed,
) )
from ..utils import dd_to_dms_str, findfiles from ..utils import dd_to_dms_str, findfiles, noop
ExportResults = namedtuple( ExportResults = namedtuple(
"ExportResults", "ExportResults",
["exported", "new", "updated", "skipped", "exif_updated", "touched"], [
"exported",
"new",
"updated",
"skipped",
"exif_updated",
"touched",
"converted_to_jpeg",
"sidecar_json_written",
"sidecar_json_skipped",
"sidecar_xmp_written",
"sidecar_xmp_skipped",
],
) )
# hexdigest is not a class method, don't import this into PhotoInfo
def hexdigest(strval):
""" hexdigest of a string, using blake2b """
h = hashlib.blake2b(digest_size=20)
h.update(bytes(strval, "utf-8"))
return h.hexdigest()
# _export_photo_uuid_applescript is not a class method, don't import this into PhotoInfo # _export_photo_uuid_applescript is not a class method, don't import this into PhotoInfo
def _export_photo_uuid_applescript( def _export_photo_uuid_applescript(
uuid, uuid,
@@ -149,11 +170,9 @@ def _export_photo_uuid_applescript(
and path.suffix.lower() == ".mov" and path.suffix.lower() == ".mov"
): ):
# it's the .mov part of live photo but not requested, so don't export # it's the .mov part of live photo but not requested, so don't export
logging.debug(f"Skipping live photo file {path}")
continue continue
if len(exported_files) > 1 and burst and path.stem != filename_stem: if len(exported_files) > 1 and burst and path.stem != filename_stem:
# skip any burst photo that's not the one we asked for # skip any burst photo that's not the one we asked for
logging.debug(f"Skipping burst photo file {path}")
continue continue
if filestem: if filestem:
# rename the file based on filestem, keeping original extension # rename the file based on filestem, keeping original extension
@@ -161,7 +180,6 @@ def _export_photo_uuid_applescript(
else: else:
# use the name Photos provided # use the name Photos provided
dest_new = dest / path.name dest_new = dest / path.name
logging.debug(f"exporting {path} to dest_new: {dest_new}")
if not dry_run: if not dry_run:
FileUtil.copy(str(path), str(dest_new)) FileUtil.copy(str(path), str(dest_new))
exported_paths.append(str(dest_new)) exported_paths.append(str(dest_new))
@@ -324,6 +342,7 @@ def export2(
jpeg_quality=1.0, jpeg_quality=1.0,
ignore_date_modified=False, ignore_date_modified=False,
use_photokit=False, use_photokit=False,
verbose=None,
): ):
""" export photo, like export but with update and dry_run options """ export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised dest: must be valid destination path or exception raised
@@ -367,6 +386,7 @@ def export2(
convert_to_jpeg: boolean; if True, converts non-jpeg images to jpeg 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. 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 ignore_date_modified: for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
where each field is a list of file paths where each field is a list of file paths
@@ -383,6 +403,12 @@ def export2(
if export_db is None: if export_db is None:
export_db = ExportDBNoOp() export_db = ExportDBNoOp()
if verbose is None:
verbose = noop
elif not callable(verbose):
raise TypeError("verbose must be callable")
self._verbose = verbose
# suffix to add to edited files # suffix to add to edited files
# e.g. name will be filename_edited.jpg # e.g. name will be filename_edited.jpg
edited_identifier = "_edited" edited_identifier = "_edited"
@@ -402,6 +428,9 @@ def export2(
# list of all files with utime touched (touch_file = True) # list of all files with utime touched (touch_file = True)
touched_files = [] touched_files = []
# list of all files convereted to jpeg
converted_to_jpeg_files = []
# check edited and raise exception trying to export edited version of # check edited and raise exception trying to export edited version of
# photo that hasn't been edited # photo that hasn't been edited
if edited and not self.hasadjustments: if edited and not self.hasadjustments:
@@ -488,11 +517,6 @@ def export2(
f"Cannot export edited photo if path_edited is None" f"Cannot export edited photo if path_edited is None"
) )
else: else:
if self.ismissing:
logging.debug(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is not None: if self.path is not None:
src = self.path src = self.path
else: else:
@@ -501,32 +525,22 @@ def export2(
if not os.path.isfile(src): if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist") raise FileNotFoundError(f"{src} does not appear to exist")
if not _check_export_suffix(src, dest, edited):
logging.debug(
f"Invalid destination suffix: {dest.suffix} for {self.path}, "
+ f"edited={edited}, path_edited={self.path_edited}, "
+ f"original_filename={self.original_filename}, filename={self.filename}"
)
# found source now try to find right destination # found source now try to find right destination
if update and dest.exists(): if update and dest.exists():
# destination exists, check to see if destination is the right UUID # destination exists, check to see if destination is the right UUID
dest_uuid = export_db.get_uuid_for_file(dest) dest_uuid = export_db.get_uuid_for_file(dest)
if dest_uuid is None and fileutil.cmp(src, dest): if dest_uuid is None and fileutil.cmp(src, dest):
# might be exporting into a pre-ExportDB folder or the DB got deleted # might be exporting into a pre-ExportDB folder or the DB got deleted
logging.debug(
f"Found matching file with blank uuid: {self.uuid}, {dest}"
)
dest_uuid = self.uuid dest_uuid = self.uuid
export_db.set_data( export_db.set_data(
dest, filename=dest,
self.uuid, uuid=self.uuid,
fileutil.file_sig(dest), orig_stat=fileutil.file_sig(dest),
(None, None, None), exif_stat=(None, None, None),
(None, None, None), converted_stat=(None, None, None),
(None, None, None), edited_stat=(None, None, None),
self.json(), info_json=self.json(),
None, exif_json=None,
) )
if dest_uuid != self.uuid: if dest_uuid != self.uuid:
# not the right file, find the right one # not the right file, find the right one
@@ -545,14 +559,14 @@ def export2(
dest = pathlib.Path(file_) dest = pathlib.Path(file_)
found_match = True found_match = True
export_db.set_data( export_db.set_data(
dest, filename=dest,
self.uuid, uuid=self.uuid,
fileutil.file_sig(dest), orig_stat=fileutil.file_sig(dest),
(None, None, None), exif_stat=(None, None, None),
(None, None, None), converted_stat=(None, None, None),
(None, None, None), edited_stat=(None, None, None),
self.json(), info_json=self.json(),
None, exif_json=None,
) )
break break
@@ -589,6 +603,7 @@ def export2(
update_updated_files = results.updated update_updated_files = results.updated
update_skipped_files = results.skipped update_skipped_files = results.skipped
touched_files = results.touched touched_files = results.touched
converted_to_jpeg_files = results.converted_to_jpeg
# copy live photo associated .mov if requested # copy live photo associated .mov if requested
if live_photo and self.live_photo: if live_photo and self.live_photo:
@@ -596,9 +611,6 @@ def export2(
src_live = self.path_live_photo src_live = self.path_live_photo
if src_live is not None: if src_live is not None:
logging.debug(
f"Exporting live photo video of {filename} as {live_name.name}"
)
results = self._export_photo( results = self._export_photo(
src_live, src_live,
live_name, live_name,
@@ -617,8 +629,7 @@ def export2(
update_updated_files.extend(results.updated) update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped) update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched) touched_files.extend(results.touched)
else: converted_to_jpeg_files.extend(results.converted_to_jpeg)
logging.debug(f"Skipping missing live movie for {filename}")
# copy associated RAW image if requested # copy associated RAW image if requested
if raw_photo and self.has_raw: if raw_photo and self.has_raw:
@@ -626,7 +637,6 @@ def export2(
raw_ext = raw_path.suffix raw_ext = raw_path.suffix
raw_name = dest.parent / f"{dest.stem}{raw_ext}" raw_name = dest.parent / f"{dest.stem}{raw_ext}"
if raw_path is not None: if raw_path is not None:
logging.debug(f"Exporting RAW photo of {filename} as {raw_name.name}")
results = self._export_photo( results = self._export_photo(
raw_path, raw_path,
raw_name, raw_name,
@@ -646,8 +656,7 @@ def export2(
update_updated_files.extend(results.updated) update_updated_files.extend(results.updated)
update_skipped_files.extend(results.skipped) update_skipped_files.extend(results.skipped)
touched_files.extend(results.touched) touched_files.extend(results.touched)
else: converted_to_jpeg_files.extend(results.converted_to_jpeg)
logging.debug(f"Skipping missing RAW photo for {filename}")
else: else:
# use_photo_export # use_photo_export
exported = [] exported = []
@@ -748,8 +757,9 @@ def export2(
) )
# export metadata # export metadata
sidecar_json_files_skipped = []
sidecar_json_files_written = []
if sidecar_json: if sidecar_json:
logging.debug("writing exiftool_json_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json") sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.json")
sidecar_str = self._exiftool_json_sidecar( sidecar_str = self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords, use_albums_as_keywords=use_albums_as_keywords,
@@ -758,15 +768,36 @@ def export2(
description_template=description_template, description_template=description_template,
ignore_date_modified=ignore_date_modified, ignore_date_modified=ignore_date_modified,
) )
if not dry_run: sidecar_digest = hexdigest(sidecar_str)
try: old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
sidecar_filename
)
write_sidecar = (
not update
or (update and not sidecar_filename.exists())
or (
update
and (sidecar_digest != old_sidecar_digest)
or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
)
)
if write_sidecar:
verbose(f"Writing exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str) self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e: export_db.set_sidecar_for_file(
logging.warning(f"Error writing json sidecar to {sidecar_filename}") sidecar_filename,
raise e sidecar_digest,
fileutil.file_sig(sidecar_filename),
)
else:
verbose(f"Skipped up to date exiftool JSON sidecar {sidecar_filename}")
sidecar_json_files_skipped.append(str(sidecar_filename))
sidecar_xmp_files_skipped = []
sidecar_xmp_files_written = []
if sidecar_xmp: if sidecar_xmp:
logging.debug("writing xmp_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp") sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}{dest.suffix}.xmp")
sidecar_str = self._xmp_sidecar( sidecar_str = self._xmp_sidecar(
use_albums_as_keywords=use_albums_as_keywords, use_albums_as_keywords=use_albums_as_keywords,
@@ -775,12 +806,32 @@ def export2(
description_template=description_template, description_template=description_template,
extension=dest.suffix[1:] if dest.suffix else None, extension=dest.suffix[1:] if dest.suffix else None,
) )
if not dry_run: sidecar_digest = hexdigest(sidecar_str)
try: old_sidecar_digest, sidecar_sig = export_db.get_sidecar_for_file(
sidecar_filename
)
write_sidecar = (
not update
or (update and not sidecar_filename.exists())
or (
update
and (sidecar_digest != old_sidecar_digest)
or not fileutil.cmp_file_sig(sidecar_filename, sidecar_sig)
)
)
if write_sidecar:
verbose(f"Writing XMP sidecar {sidecar_filename}")
sidecar_xmp_files_written.append(str(sidecar_filename))
if not dry_run:
self._write_sidecar(sidecar_filename, sidecar_str) self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e: export_db.set_sidecar_for_file(
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}") sidecar_filename,
raise e sidecar_digest,
fileutil.file_sig(sidecar_filename),
)
else:
verbose(f"Skipped up to date XMP sidecar {sidecar_filename}")
sidecar_xmp_files_skipped.append(str(sidecar_filename))
# if exiftool, write the metadata # if exiftool, write the metadata
if update: if update:
@@ -791,7 +842,6 @@ def export2(
exif_files_updated = [] exif_files_updated = []
if exiftool and update and exif_files: if exiftool and update and exif_files:
for exported_file in exif_files: for exported_file in exif_files:
logging.debug(f"checking exif for {exported_file}")
files_are_different = False files_are_different = False
old_data = export_db.get_exifdata_for_file(exported_file) old_data = export_db.get_exifdata_for_file(exported_file)
if old_data is not None: if old_data is not None:
@@ -811,6 +861,7 @@ def export2(
if old_data is None or files_are_different: if old_data is None or files_are_different:
# didn't have old data, assume we need to write it # didn't have old data, assume we need to write it
# or files were different # or files were different
verbose(f"Writing metadata with exiftool for {exported_file}")
if not dry_run: if not dry_run:
self._write_exif_data( self._write_exif_data(
exported_file, exported_file,
@@ -834,8 +885,11 @@ def export2(
exported_file, fileutil.file_sig(exported_file) exported_file, fileutil.file_sig(exported_file)
) )
exif_files_updated.append(exported_file) exif_files_updated.append(exported_file)
else:
verbose(f"Skipped up to date exiftool metadata for {exported_file}")
elif exiftool and exif_files: elif exiftool and exif_files:
for exported_file in exif_files: for exported_file in exif_files:
verbose(f"Writing metadata with exiftool for {exported_file}")
if not dry_run: if not dry_run:
self._write_exif_data( self._write_exif_data(
exported_file, exported_file,
@@ -863,6 +917,7 @@ def export2(
if touch_file: if touch_file:
for exif_file in exif_files_updated: for exif_file in exif_files_updated:
verbose(f"Updating file modification time for {exif_file}")
touched_files.append(exif_file) touched_files.append(exif_file)
ts = int(self.date.timestamp()) ts = int(self.date.timestamp())
fileutil.utime(exif_file, (ts, ts)) fileutil.utime(exif_file, (ts, ts))
@@ -876,6 +931,11 @@ def export2(
update_skipped_files, update_skipped_files,
exif_files_updated, exif_files_updated,
touched_files, touched_files,
converted_to_jpeg_files,
sidecar_json_files_written,
sidecar_json_files_skipped,
sidecar_xmp_files_written,
sidecar_xmp_files_skipped,
) )
return results return results
@@ -932,6 +992,7 @@ def _export_photo(
update_new_files = [] update_new_files = []
update_skipped_files = [] update_skipped_files = []
touched_files = [] touched_files = []
converted_to_jpeg_files = []
dest_str = str(dest) dest_str = str(dest)
dest_exists = dest.exists() dest_exists = dest.exists()
@@ -998,13 +1059,11 @@ def _export_photo(
else: else:
# update, destination doesn't exist (new file) # update, destination doesn't exist (new file)
logging.debug(f"Update: exporting new file with {op_desc} {src} {dest}")
update_new_files.append(dest_str) update_new_files.append(dest_str)
if touch_file: if touch_file:
touched_files.append(dest_str) touched_files.append(dest_str)
else: else:
# not update, export the file # not update, export the file
logging.debug(f"Exporting file with {op_desc} {src} {dest}")
exported_files.append(dest_str) exported_files.append(dest_str)
if touch_file: if touch_file:
sig = fileutil.file_sig(src) sig = fileutil.file_sig(src)
@@ -1016,9 +1075,6 @@ def _export_photo(
edited_stat = fileutil.file_sig(src) if edited else (None, None, None) edited_stat = fileutil.file_sig(src) if edited else (None, None, None)
if dest_exists and (update or overwrite): if dest_exists and (update or overwrite):
# need to remove the destination first # need to remove the destination first
logging.debug(
f"Update: removing existing file prior to {op_desc} {src} {dest}"
)
fileutil.unlink(dest) fileutil.unlink(dest)
if export_as_hardlink: if export_as_hardlink:
fileutil.hardlink(src, dest) fileutil.hardlink(src, dest)
@@ -1026,18 +1082,19 @@ def _export_photo(
# use convert_to_jpeg to export the file # use convert_to_jpeg to export the file
fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality) fileutil.convert_to_jpeg(src, dest_str, compression_quality=jpeg_quality)
converted_stat = fileutil.file_sig(dest_str) converted_stat = fileutil.file_sig(dest_str)
converted_to_jpeg_files.append(dest_str)
else: else:
fileutil.copy(src, dest_str, norsrc=no_xattr) fileutil.copy(src, dest_str, norsrc=no_xattr)
export_db.set_data( export_db.set_data(
dest_str, filename=dest_str,
self.uuid, uuid=self.uuid,
fileutil.file_sig(dest_str), orig_stat=fileutil.file_sig(dest_str),
(None, None, None), exif_stat=(None, None, None),
converted_stat, converted_stat=converted_stat,
edited_stat, edited_stat=edited_stat,
self.json(), info_json=self.json(),
None, exif_json=None,
) )
if touched_files: if touched_files:
@@ -1051,6 +1108,11 @@ def _export_photo(
update_skipped_files, update_skipped_files,
[], [],
touched_files, touched_files,
converted_to_jpeg_files,
[],
[],
[],
[],
) )
@@ -1121,9 +1183,9 @@ def _exiftool_dict(
IPTC:Keywords (may include album name, person name, or template) IPTC:Keywords (may include album name, person name, or template)
XMP:Subject XMP:Subject
XMP:PersonInImage XMP:PersonInImage
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:GPSLatitude, EXIF:GPSLongitude EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition EXIF:GPSPosition
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:DateTimeOriginal EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal EXIF:OffsetTimeOriginal
EXIF:ModifyDate EXIF:ModifyDate
@@ -1283,9 +1345,9 @@ def _exiftool_json_sidecar(
IPTC:Keywords (may include album name, person name, or template) IPTC:Keywords (may include album name, person name, or template)
XMP:Subject XMP:Subject
XMP:PersonInImage XMP:PersonInImage
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:GPSLatitude, EXIF:GPSLongitude EXIF:GPSLatitude, EXIF:GPSLongitude
EXIF:GPSPosition EXIF:GPSPosition
EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
EXIF:DateTimeOriginal EXIF:DateTimeOriginal
EXIF:OffsetTimeOriginal EXIF:OffsetTimeOriginal
EXIF:ModifyDate EXIF:ModifyDate

View File

@@ -91,9 +91,10 @@ class PhotoInfo:
and self.raw_original and self.raw_original
): ):
# return the JPEG version as that's what Photos 5+ does # return the JPEG version as that's what Photos 5+ does
return self._info["raw_pair_info"]["originalFilename"] original_name = self._info["raw_pair_info"]["originalFilename"]
else: else:
return self._info["originalFilename"] original_name = self._info["originalFilename"]
return original_name or self.filename
@property @property
def date(self): def date(self):

View File

@@ -80,6 +80,58 @@ def path_to_NSURL(path):
return url return url
def check_photokit_authorization():
""" Check authorization to use user's Photos Library
Returns:
True if user has authorized access to the Photos library, otherwise False
"""
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
return auth_status == Photos.PHAuthorizationStatusAuthorized
def request_photokit_authorization():
""" Request authorization to user's Photos Library
Returns:
authorization status
Note: In actual practice, the terminal process running the python code
will do the actual request.
"""
(_, major, _) = _get_os_version()
def handler(status):
pass
auth_status = 0
if int(major) < 16:
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(handler)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
else:
# requestAuthorization deprecated in 10.16/11.0
# but requestAuthorizationForAccessLevel not yet implemented in pyobjc (will be in ver 7.0)
# https://developer.apple.com/documentation/photokit/phphotolibrary/3616053-requestauthorizationforaccesslev?language=objc
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(handler)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
return auth_status
### exceptions ### exceptions
class PhotoKitError(Exception): class PhotoKitError(Exception):
"""Base class for exceptions in this module. """ """Base class for exceptions in this module. """
@@ -1051,12 +1103,6 @@ class PhotoLibrary:
# get image manager and request options # get image manager and request options
self._phimagemanager = Photos.PHCachingImageManager.defaultManager() self._phimagemanager = Photos.PHCachingImageManager.defaultManager()
def _auth_status(self, status):
""" Handler for requestAuthorization_ """
# This doesn't actually get called but requestAuthorization needs a callable handler
# The Terminal will handle the actual authorization when called
pass
def request_authorization(self): def request_authorization(self):
""" Request authorization to user's Photos Library """ Request authorization to user's Photos Library
@@ -1064,33 +1110,8 @@ class PhotoLibrary:
authorization status authorization status
""" """
(_, major, _) = _get_os_version() self.auth_status = request_photokit_authorization()
return self.auth_status
auth_status = 0
if int(major) < 16:
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(self._auth_status)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
else:
# requestAuthorization deprecated in 10.16/11.0
# but requestAuthorizationForAccessLevel not yet implemented in pyobjc (will be in ver 7.0)
# https://developer.apple.com/documentation/photokit/phphotolibrary/3616053-requestauthorizationforaccesslev?language=objc
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status != Photos.PHAuthorizationStatusAuthorized:
# it seems the first try fails after Terminal prompts user for access so try again
for _ in range(2):
Photos.PHPhotoLibrary.requestAuthorization_(self._auth_status)
auth_status = Photos.PHPhotoLibrary.authorizationStatus()
if auth_status == Photos.PHAuthorizationStatusAuthorized:
break
self.auth_status = auth_status
return auth_status
def fetch_uuid_list(self, uuid_list): def fetch_uuid_list(self, uuid_list):
""" fetch PHAssets with uuids in uuid_list """ fetch PHAssets with uuids in uuid_list

View File

@@ -18,6 +18,7 @@ from functools import partial
from ._constants import _UNKNOWN_PERSON from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter from .datetime_formatter import DateTimeFormatter
from .exiftool import ExifTool
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
# ensure locale set to user's locale # ensure locale set to user's locale
@@ -126,6 +127,10 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{label}": "Image categorization label associated with a photo (Photos 5 only)", "{label}": "Image categorization label associated with a photo (Photos 5 only)",
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)", "{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)", "{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5 only)",
"{exiftool:GROUP:TAGNAME}": "Use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. "
"E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. "
"See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) "
"as used in `exiftool -G`. exiftool must be installed in the path to use this template.",
} }
# Just the multi-valued substitution names without the braces # Just the multi-valued substitution names without the braces
@@ -150,6 +155,62 @@ class PhotoTemplate:
# gets initialized in get_template_value # gets initialized in get_template_value
self.today = None self.today = None
def make_subst_function(
self, none_str, filename, dirname, replacement, get_func=None
):
""" returns: substitution function for use in re.sub
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
if get_func is None:
# used by make_subst_function to get the value for a template substitution
get_func = partial(
self.get_template_value,
filename=filename,
dirname=dirname,
replacement=replacement,
)
# closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups != 5:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
delim = matchobj.group(1)
field = matchobj.group(2)
path_sep = matchobj.group(3)
bool_val = matchobj.group(4)
default = matchobj.group(5)
# drop the '+' on delim
delim = delim[:-1] if delim is not None else None
# drop () from path_sep
path_sep = path_sep.strip("()") if path_sep is not None else None
# drop the ? on bool_val
bool_val = bool_val[1:] if bool_val is not None else None
# drop the comma on default
default_val = default[1:] if default is not None else None
try:
val = get_func(field, default_val, bool_val, delim, path_sep)
except ValueError:
return matchobj.group(0)
if val is None:
# field valid but didn't match a value
if default == ",":
val = ""
else:
val = default_val if default_val is not None else none_str
return val
return subst
def render( def render(
self, self,
template, template,
@@ -208,60 +269,7 @@ class PhotoTemplate:
if type(template) is not str: if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}") raise TypeError(f"template must be type str, not {type(template)}")
# used by make_subst_function to get the value for a template substitution subst_func = self.make_subst_function(none_str, filename, dirname, replacement)
get_func = partial(
self.get_template_value,
filename=filename,
dirname=dirname,
replacement=replacement,
)
def make_subst_function(self, none_str, get_func=get_func):
""" returns: substitution function for use in re.sub
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
# closure to capture photo, none_str, filename, dirname in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 5:
delim = matchobj.group(1)
field = matchobj.group(2)
path_sep = matchobj.group(3)
bool_val = matchobj.group(4)
default = matchobj.group(5)
# drop the '+' on delim
delim = delim[:-1] if delim is not None else None
# drop () from path_sep
path_sep = path_sep.strip("()") if path_sep is not None else None
# drop the ? on bool_val
bool_val = bool_val[1:] if bool_val is not None else None
# drop the comma on default
default_val = default[1:] if default is not None else None
try:
val = get_func(field, default_val, bool_val, delim, path_sep)
except ValueError:
return matchobj.group(0)
if val is None:
# field valid but didn't match a value
if default == ",":
val = ""
else:
val = default_val if default_val is not None else none_str
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
return subst
subst_func = make_subst_function(self, none_str)
# do the replacements # do the replacements
rendered = re.sub(regex, subst_func, template) rendered = re.sub(regex, subst_func, template)
@@ -289,88 +297,28 @@ class PhotoTemplate:
# '2011/Album2/keyword1/person1', # '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',] # '2011/Album2/keyword2/person1',]
rendered_strings = [rendered] rendered_strings = self._render_multi_valued_templates(
for field in MULTI_VALUE_SUBSTITUTIONS: rendered,
# Build a regex that matches only the field being processed none_str,
re_str = ( path_sep,
r"(?<!\{)\{" # match { but not {{ expand_inplace,
+ r"([^}]*\+)?" # group 1: optional DELIM+ inplace_sep,
+ r"(" filename,
+ field # group 2: field name dirname,
+ r")" replacement,
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP) )
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys()) # process exiftool: templates
new_strings = {} rendered_strings = self._render_exiftool_template(
rendered_strings,
for str_template in rendered_strings: none_str,
matches = regex_multi.search(str_template) path_sep,
if matches: expand_inplace,
path_sep = ( inplace_sep,
matches.group(3).strip("()") filename,
if matches.group(3) is not None dirname,
else path_sep replacement,
) )
values = self.get_template_value_multi(
field,
path_sep,
filename=filename,
dirname=dirname,
replacement=replacement,
)
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1] if matches.group(1) is not None else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = delim.join(sorted(values)) if values and values[0] else None
def lookup_template_value_multi(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(f"Unexpected value: {lookup_value}")
subst = make_subst_function(
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = {new_string}
else:
# create a new template string for each value
for val in values:
def lookup_template_value_multi(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = make_subst_function(
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = list(new_strings.keys())
# find any {fields} that weren't replaced # find any {fields} that weren't replaced
unmatched = [] unmatched = []
@@ -396,6 +344,244 @@ class PhotoTemplate:
return rendered_strings, unmatched return rendered_strings, unmatched
def _render_multi_valued_templates(
self,
rendered,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
):
rendered_strings = [rendered]
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
for field in MULTI_VALUE_SUBSTITUTIONS:
# Build a regex that matches only the field being processed
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"("
+ field # group 2: field name
+ r")"
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
else path_sep
)
values = self.get_template_value_multi(
field,
path_sep,
filename=filename,
dirname=dirname,
replacement=replacement,
)
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1]
if matches.group(1) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = (
delim.join(sorted(values))
if values and values[0]
else None
)
def lookup_template_value_multi(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = list({new_string})
else:
# create a new template string for each value
for val in values:
def lookup_template_value_multi(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_multi,
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
def _render_exiftool_template(
self,
rendered_strings,
none_str,
path_sep,
expand_inplace,
inplace_sep,
filename,
dirname,
replacement,
):
# TODO: lots of code commonality with render_multi_valued_templates -- combine or pull out
# TODO: put these in globals
if path_sep is None:
path_sep = os.path.sep
if inplace_sep is None:
inplace_sep = ","
# Build a regex that matches only the field being processed
# todo: pull out regexes into globals?
re_str = (
r"(?<!\{)\{" # match { but not {{
+ r"([^}]*\+)?" # group 1: optional DELIM+
+ r"(exiftool:[^\\,}+\?]+)" # group 3 field name
+ r"(\([^{}\)]*\))?" # group 3: optional (PATH_SEP)
+ r"(\?[^\\,}]*)?" # group 4: optional ?TRUE_VALUE for boolean fields
+ r"(,[\w\=\;\-\%. ]*)?" # group 5: optional ,DEFAULT
+ r"(?=\}(?!\}))\}" # match } but not }}
)
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, dict to avoid repeats (dict.keys())
new_rendered_strings = []
while new_rendered_strings != rendered_strings:
new_rendered_strings = rendered_strings
new_strings = {}
for str_template in rendered_strings:
matches = regex_multi.search(str_template)
if matches:
# allmatches = regex_multi.finditer(str_template)
# for matches in allmatches:
path_sep = (
matches.group(3).strip("()")
if matches.group(3) is not None
else path_sep
)
field = matches.group(2)
subfield = field[9:]
if not self.photo.path:
values = []
else:
exif = ExifTool(self.photo.path)
exifdict = exif.asdict()
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
subfield = subfield.lower()
if subfield in exifdict:
values = exifdict[subfield]
values = (
[values] if not isinstance(values, list) else values
)
else:
values = [None]
if expand_inplace or matches.group(1) is not None:
delim = (
matches.group(1)[:-1]
if matches.group(1) is not None
else inplace_sep
)
# instead of returning multiple strings, join values into a single string
val = (
delim.join(sorted(values)) if values and values[0] else None
)
def lookup_template_value_exif(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(f"Unexpected value: {lookup_value}")
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = list({new_string})
else:
# create a new template string for each value
for val in values:
def lookup_template_value_exif(lookup_value, *_):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification
_ is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = self.make_subst_function(
none_str,
filename,
dirname,
replacement,
get_func=lookup_template_value_exif,
)
new_string = regex_multi.sub(subst, str_template)
new_strings[new_string] = 1
# update rendered_strings for the next field to process
rendered_strings = sorted(list(new_strings.keys()))
return rendered_strings
def get_template_value( def get_template_value(
self, self,
field, field,
@@ -681,6 +867,7 @@ class PhotoTemplate:
""" """
""" return list of values for a multi-valued template field """ """ return list of values for a multi-valued template field """
values = []
if field == "album": if field == "album":
values = self.photo.albums values = self.photo.albums
elif field == "keyword": elif field == "keyword":
@@ -724,7 +911,7 @@ class PhotoTemplate:
values = [ values = [
f"{comment.user}: {comment.text}" for comment in self.photo.comments f"{comment.user}: {comment.text}" for comment in self.photo.comments
] ]
else: elif not field.startswith("exiftool:"):
raise ValueError(f"Unhandled template value: {field}") raise ValueError(f"Unhandled template value: {field}")
# sanitize directory names if needed, folder_album handled differently above # sanitize directory names if needed, folder_album handled differently above

View File

@@ -12,7 +12,7 @@
% if desc is None: % if desc is None:
<dc:description></dc:description> <dc:description></dc:description>
% else: % else:
<dc:description>${desc}</dc:description> <dc:description>${desc | x}</dc:description>
% endif % endif
</%def> </%def>
@@ -20,7 +20,7 @@
% if title is None: % if title is None:
<dc:title></dc:title> <dc:title></dc:title>
% else: % else:
<dc:title>${title}</dc:title> <dc:title>${title | x}</dc:title>
% endif % endif
</%def> </%def>
@@ -30,7 +30,7 @@
<dc:subject> <dc:subject>
<rdf:Seq> <rdf:Seq>
% for subj in subject: % for subj in subject:
<rdf:li>${subj}</rdf:li> <rdf:li>${subj | x}</rdf:li>
% endfor % endfor
</rdf:Seq> </rdf:Seq>
</dc:subject> </dc:subject>
@@ -48,7 +48,7 @@
<Iptc4xmpExt:PersonInImage> <Iptc4xmpExt:PersonInImage>
<rdf:Bag> <rdf:Bag>
% for person in persons: % for person in persons:
<rdf:li>${person}</rdf:li> <rdf:li>${person | x}</rdf:li>
% endfor % endfor
</rdf:Bag> </rdf:Bag>
</Iptc4xmpExt:PersonInImage> </Iptc4xmpExt:PersonInImage>
@@ -60,7 +60,7 @@
<digiKam:TagsList> <digiKam:TagsList>
<rdf:Seq> <rdf:Seq>
% for keyword in keywords: % for keyword in keywords:
<rdf:li>${keyword}</rdf:li> <rdf:li>${keyword | x}</rdf:li>
% endfor % endfor
</rdf:Seq> </rdf:Seq>
</digiKam:TagsList> </digiKam:TagsList>
@@ -81,10 +81,8 @@
<%def name="gps_info(latitude, longitude)"> <%def name="gps_info(latitude, longitude)">
% if latitude is not None and longitude is not None: % if latitude is not None and longitude is not None:
<exif:GPSLongitudeRef>${"E" if longitude >= 0 else "W"}</exif:GPSLongitudeRef> <exif:GPSLongitude>${int(abs(longitude))},${(abs(longitude) % 1) * 60}${"E" if longitude >= 0 else "W"}</exif:GPSLongitude>
<exif:GPSLongitude>${abs(longitude)}</exif:GPSLongitude> <exif:GPSLatitude>${int(abs(latitude))},${(abs(latitude) % 1) * 60}${"N" if latitude >= 0 else "S"}</exif:GPSLatitude>
<exif:GPSLatitude>${abs(latitude)}</exif:GPSLatitude>
<exif:GPSLatitudeRef>${"N" if latitude >= 0 else "S"}</exif:GPSLatitudeRef>
% endif % endif
</%def> </%def>

View File

@@ -177,6 +177,12 @@ RAW_DICT = {
), ),
} }
ORIGINAL_FILENAME_DICT = {
"uuid": "D79B8D77-BFFC-460B-9312-034F2877D35B",
"filename": "D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg",
"original_filename": "Pumkins2.jpg",
}
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def photosdb(): def photosdb():
@@ -864,6 +870,27 @@ def test_export_14(photosdb, caplog):
assert "Invalid destination suffix" not in caplog.text assert "Invalid destination suffix" not in caplog.text
def test_export_no_original_filename(photosdb):
# test export OK if original filename is null
# issue #267
tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
dest = tempdir.name
photos = photosdb.photos(uuid=[UUID_DICT["export"]])
# monkey patch original_filename for testing
original_filename = photos[0]._info["originalFilename"]
photos[0]._info["originalFilename"] = None
filename = f"{photos[0].uuid}.jpeg"
expected_dest = os.path.join(dest, filename)
got_dest = photos[0].export(dest)[0]
assert got_dest == expected_dest
assert os.path.isfile(got_dest)
photos[0]._info["originalFilename"] = original_filename
def test_eq(): def test_eq():
""" Test equality of two PhotoInfo objects """ """ Test equality of two PhotoInfo objects """
@@ -1070,3 +1097,18 @@ def test_verbose(capsys):
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB, verbose=print) photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB, verbose=print)
captured = capsys.readouterr() captured = capsys.readouterr()
assert "Processing database" in captured.out assert "Processing database" in captured.out
def test_original_filename(photosdb):
""" test original filename """
uuid = ORIGINAL_FILENAME_DICT["uuid"]
photo = photosdb.get_photo(uuid)
assert photo.original_filename == ORIGINAL_FILENAME_DICT["original_filename"]
assert photo.filename == ORIGINAL_FILENAME_DICT["filename"]
# monkey patch
original_filename = photo._info["originalFilename"]
photo._info["originalFilename"] = None
assert photo.original_filename == ORIGINAL_FILENAME_DICT["filename"]
photo._info["originalFilename"] = original_filename

View File

@@ -17,6 +17,7 @@ PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary"
PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary" PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary"
PHOTOS_DB_15_5 = "tests/Test-10.15.5.photoslibrary" PHOTOS_DB_15_5 = "tests/Test-10.15.5.photoslibrary"
PHOTOS_DB_15_6 = "tests/Test-10.15.6.photoslibrary" PHOTOS_DB_15_6 = "tests/Test-10.15.6.photoslibrary"
PHOTOS_DB_15_7 = "tests/Test-10.15.7.photoslibrary"
PHOTOS_DB_TOUCH = PHOTOS_DB_15_6 PHOTOS_DB_TOUCH = PHOTOS_DB_15_6
PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary" PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary"
@@ -65,6 +66,7 @@ CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"]
CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"] CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"]
CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten" CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten"
CLI_EXPORT_ORIGINAL_SUFFIX = "_original"
CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"Pumkins1.jpg", "Pumkins1.jpg",
@@ -77,6 +79,16 @@ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [
"wedding_bearbeiten.jpeg", "wedding_bearbeiten.jpeg",
] ]
CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [
"Pumkins1_original.jpg",
"Pumkins2_original.jpg",
"Pumpkins3_original.jpg",
"St James Park_original.jpg",
"St James Park_edited.jpeg",
"Tulips_original.jpg",
"wedding_original.jpg",
"wedding_edited.jpeg",
]
CLI_EXPORT_FILENAMES_CURRENT = [ CLI_EXPORT_FILENAMES_CURRENT = [
"1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg", "1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg",
@@ -976,7 +988,9 @@ def test_export_exiftool_ignore_date_modified():
) )
assert result.exit_code == 0 assert result.exit_code == 0
exif = ExifTool(CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"]).asdict() exif = ExifTool(
CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]["File:FileName"]
).asdict()
for key in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]: for key in CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid]:
assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key] assert exif[key] == CLI_EXIFTOOL_IGNORE_DATE_MODIFIED[uuid][key]
@@ -1008,6 +1022,33 @@ def test_export_edited_suffix():
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX) assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX)
def test_export_original_suffix():
""" test export with --original-suffix """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--original-suffix",
CLI_EXPORT_ORIGINAL_SUFFIX,
"-V",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX)
@pytest.mark.skipif( @pytest.mark.skipif(
"OSXPHOTOS_TEST_CONVERT" not in os.environ, "OSXPHOTOS_TEST_CONVERT" not in os.environ,
reason="Skip if running in Github actions, no GPU.", reason="Skip if running in Github actions, no GPU.",
@@ -1734,6 +1775,142 @@ def test_export_sidecar_templates():
) )
def test_export_sidecar_update():
""" test sidecar don't update if not changed and do update if changed """
import datetime
import glob
import os
import os.path
import osxphotos
from osxphotos.fileutil import FileUtil
from osxphotos.__main__ import cli
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
],
)
assert result.exit_code == 0
assert "Writing XMP sidecar" in result.output
assert "Writing exiftool JSON sidecar" in result.output
# delete a sidecar file and run update
fileutil = FileUtil()
fileutil.unlink(CLI_EXPORT_SIDECAR_FILENAMES[1])
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
],
)
assert result.exit_code == 0
assert "Skipped up to date XMP sidecar" in result.output
assert "Writing exiftool JSON sidecar" in result.output
# run update again, no sidecar files should update
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
],
)
assert result.exit_code == 0
assert "Skipped up to date XMP sidecar" in result.output
assert "Skipped up to date exiftool JSON sidecar" in result.output
# touch a file and export again
ts = datetime.datetime.now().timestamp() + 1000
fileutil.utime(CLI_EXPORT_SIDECAR_FILENAMES[2], (ts, ts))
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
],
)
assert result.exit_code == 0
assert "Writing XMP sidecar" in result.output
assert "Skipped up to date exiftool JSON sidecar" in result.output
# run update again, no sidecar files should update
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
],
)
assert result.exit_code == 0
assert "Skipped up to date XMP sidecar" in result.output
assert "Skipped up to date exiftool JSON sidecar" in result.output
# run update again with updated metadata, forcing update
result = runner.invoke(
cli,
[
"export",
"--db",
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"--sidecar=json",
"--sidecar=xmp",
f"--uuid={CLI_EXPORT_UUID}",
"-V",
"--update",
"--keyword-template",
"foo",
],
)
assert result.exit_code == 0
assert "Writing XMP sidecar" in result.output
assert "Writing exiftool JSON sidecar" in result.output
def test_export_live(): def test_export_live():
import glob import glob
import os import os
@@ -3587,3 +3764,116 @@ def test_persons():
json_got = json.loads(result.output) json_got = json.loads(result.output)
assert json_got == PERSONS_JSON assert json_got == PERSONS_JSON
def test_export_report():
""" test export with --report option """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "report.csv"],
)
assert result.exit_code == 0
assert "Writing export report" in result.output
assert os.path.exists("report.csv")
def test_export_report_not_a_file():
""" test export with --report option and bad report value """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--report", "."]
)
assert result.exit_code != 0
assert "Aborted!" in result.output
def test_export_as_hardlink_download_missing():
""" test export with incompatible export options """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, CLI_PHOTOS_DB),
".",
"-V",
"--download-missing",
"--export-as-hardlink",
".",
],
)
assert result.exit_code != 0
assert "Aborted!" in result.output
def test_export_missing():
""" test export with --missing """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--missing",
"--download-missing",
".",
],
)
assert result.exit_code == 0
assert "Exporting 2 photos" in result.output
def test_export_missing_not_download_missing():
""" test export with incompatible export options """
import glob
import os
import os.path
import osxphotos
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
result = runner.invoke(
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V", "--missing", "."]
)
assert result.exit_code != 0
assert "Aborted!" in result.output

View File

@@ -1029,7 +1029,7 @@ def test_xmp_sidecar_gps():
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"> xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
<photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension> <photoshop:SidecarForExtension>jpg</photoshop:SidecarForExtension>
<dc:description></dc:description> <dc:description></dc:description>
<dc:title>St. James's Park</dc:title> <dc:title>St. James&#39;s Park</dc:title>
<!-- keywords and persons listed in <dc:subject> as Photos does --> <!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject> <dc:subject>
<rdf:Seq> <rdf:Seq>
@@ -1038,7 +1038,7 @@ def test_xmp_sidecar_gps():
<rdf:li>London</rdf:li> <rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li> <rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li> <rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li> <rdf:li>St. James&#39;s Park</rdf:li>
</rdf:Seq> </rdf:Seq>
</dc:subject> </dc:subject>
<photoshop:DateCreated>2018-10-13T09:18:12.501000-04:00</photoshop:DateCreated> <photoshop:DateCreated>2018-10-13T09:18:12.501000-04:00</photoshop:DateCreated>
@@ -1055,7 +1055,7 @@ def test_xmp_sidecar_gps():
<rdf:li>London</rdf:li> <rdf:li>London</rdf:li>
<rdf:li>United Kingdom</rdf:li> <rdf:li>United Kingdom</rdf:li>
<rdf:li>London 2018</rdf:li> <rdf:li>London 2018</rdf:li>
<rdf:li>St. James's Park</rdf:li> <rdf:li>St. James&#39;s Park</rdf:li>
</rdf:Seq> </rdf:Seq>
</digiKam:TagsList> </digiKam:TagsList>
</rdf:Description> </rdf:Description>
@@ -1066,10 +1066,8 @@ def test_xmp_sidecar_gps():
</rdf:Description> </rdf:Description>
<rdf:Description rdf:about="" <rdf:Description rdf:about=""
xmlns:exif='http://ns.adobe.com/exif/1.0/'> xmlns:exif='http://ns.adobe.com/exif/1.0/'>
<exif:GPSLongitudeRef>W</exif:GPSLongitudeRef> <exif:GPSLongitude>0,7.908329999999999W</exif:GPSLongitude>
<exif:GPSLongitude>0.1318055</exif:GPSLongitude> <exif:GPSLatitude>51,30.21430019999997N</exif:GPSLatitude>
<exif:GPSLatitude>51.50357167</exif:GPSLatitude>
<exif:GPSLatitudeRef>N</exif:GPSLatitudeRef>
</rdf:Description> </rdf:Description>
</rdf:RDF> </rdf:RDF>
</x:xmpmeta>""" </x:xmpmeta>"""

View File

@@ -4,6 +4,8 @@ import pytest
EXIF_DATA = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "EXIF:ImageDescription": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Title": "Elder Park", "EXIF:GPSLatitude": "34 deg 55' 8.01\" S", "EXIF:GPSLongitude": "138 deg 35' 48.70\" E", "Composite:GPSPosition": "34 deg 55' 8.01\" S, 138 deg 35' 48.70\" E", "EXIF:GPSLatitudeRef": "South", "EXIF:GPSLongitudeRef": "East", "EXIF:DateTimeOriginal": "2017:06:20 17:18:56", "EXIF:OffsetTimeOriginal": "+09:30", "EXIF:ModifyDate": "2020:05:18 14:42:04"}]""" EXIF_DATA = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "EXIF:ImageDescription": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "XMP:Title": "Elder Park", "EXIF:GPSLatitude": "34 deg 55' 8.01\" S", "EXIF:GPSLongitude": "138 deg 35' 48.70\" E", "Composite:GPSPosition": "34 deg 55' 8.01\" S, 138 deg 35' 48.70\" E", "EXIF:GPSLatitudeRef": "South", "EXIF:GPSLongitudeRef": "East", "EXIF:DateTimeOriginal": "2017:06:20 17:18:56", "EXIF:OffsetTimeOriginal": "+09:30", "EXIF:ModifyDate": "2020:05:18 14:42:04"}]"""
INFO_DATA = """{"uuid": "3DD2C897-F19E-4CA6-8C22-B027D5A71907", "filename": "3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "original_filename": "IMG_4547.jpg", "date": "2017-06-20T17:18:56.518000+09:30", "description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "title": "Elder Park", "keywords": [], "labels": ["Statue", "Art"], "albums": ["AlbumInFolder"], "folders": {"AlbumInFolder": ["Folder1", "SubFolder2"]}, "persons": [], "path": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/originals/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "ismissing": false, "hasadjustments": true, "external_edit": false, "favorite": false, "hidden": false, "latitude": -34.91889167000001, "longitude": 138.59686167, "path_edited": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/resources/renders/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907_1_201_a.jpeg", "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": false, "incloud": null, "date_modified": "2020-05-18T14:42:04.608664+09:30", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Elder Park, Adelaide, South Australia, Australia, River Torrens", "names": {"field0": [], "country": ["Australia"], "state_province": ["South Australia"], "sub_administrative_area": ["Adelaide"], "city": ["Adelaide", "Adelaide"], "field5": [], "additional_city_info": ["Adelaide CBD", "Tarndanya"], "ocean": [], "area_of_interest": ["Elder Park", ""], "inland_water": ["River Torrens", "River Torrens"], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": ["River Torrens", "River Torrens"]}, "country_code": "AU", "ishome": false, "address_str": "River Torrens, Adelaide SA, Australia", "address": {"street": null, "sub_locality": "Tarndanya", "city": "Adelaide", "sub_administrative_area": "Adelaide", "state_province": "SA", "postal_code": null, "country": "Australia", "iso_country_code": "AU"}}, "exif": {"flash_fired": false, "iso": 320, "metering_mode": 3, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.058823529411764705, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}""" INFO_DATA = """{"uuid": "3DD2C897-F19E-4CA6-8C22-B027D5A71907", "filename": "3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "original_filename": "IMG_4547.jpg", "date": "2017-06-20T17:18:56.518000+09:30", "description": "\u2068Elder Park\u2069, \u2068Adelaide\u2069, \u2068Australia\u2069", "title": "Elder Park", "keywords": [], "labels": ["Statue", "Art"], "albums": ["AlbumInFolder"], "folders": {"AlbumInFolder": ["Folder1", "SubFolder2"]}, "persons": [], "path": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/originals/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", "ismissing": false, "hasadjustments": true, "external_edit": false, "favorite": false, "hidden": false, "latitude": -34.91889167000001, "longitude": 138.59686167, "path_edited": "/Users/rhet/Pictures/Test-10.15.4.photoslibrary/resources/renders/3/3DD2C897-F19E-4CA6-8C22-B027D5A71907_1_201_a.jpeg", "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": false, "incloud": null, "date_modified": "2020-05-18T14:42:04.608664+09:30", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Elder Park, Adelaide, South Australia, Australia, River Torrens", "names": {"field0": [], "country": ["Australia"], "state_province": ["South Australia"], "sub_administrative_area": ["Adelaide"], "city": ["Adelaide", "Adelaide"], "field5": [], "additional_city_info": ["Adelaide CBD", "Tarndanya"], "ocean": [], "area_of_interest": ["Elder Park", ""], "inland_water": ["River Torrens", "River Torrens"], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": ["River Torrens", "River Torrens"]}, "country_code": "AU", "ishome": false, "address_str": "River Torrens, Adelaide SA, Australia", "address": {"street": null, "sub_locality": "Tarndanya", "city": "Adelaide", "sub_administrative_area": "Adelaide", "state_province": "SA", "postal_code": null, "country": "Australia", "iso_country_code": "AU"}}, "exif": {"flash_fired": false, "iso": 320, "metering_mode": 3, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.058823529411764705, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}"""
SIDECAR_DATA = """FOO_BAR"""
EXIF_DATA2 = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "XMP:Title": "St. James's Park", "XMP:TagsList": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "IPTC:Keywords": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "XMP:Subject": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "EXIF:GPSLatitude": "51 deg 30' 12.86\" N", "EXIF:GPSLongitude": "0 deg 7' 54.50\" W", "Composite:GPSPosition": "51 deg 30' 12.86\" N, 0 deg 7' 54.50\" W", "EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", "EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:12:08 14:06:44"}]""" EXIF_DATA2 = """[{"_CreatedBy": "osxphotos, https://github.com/RhetTbull/osxphotos", "XMP:Title": "St. James's Park", "XMP:TagsList": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "IPTC:Keywords": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "XMP:Subject": ["London 2018", "St. James's Park", "England", "United Kingdom", "UK", "London"], "EXIF:GPSLatitude": "51 deg 30' 12.86\" N", "EXIF:GPSLongitude": "0 deg 7' 54.50\" W", "Composite:GPSPosition": "51 deg 30' 12.86\" N, 0 deg 7' 54.50\" W", "EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West", "EXIF:DateTimeOriginal": "2018:10:13 09:18:12", "EXIF:OffsetTimeOriginal": "-04:00", "EXIF:ModifyDate": "2019:12:08 14:06:44"}]"""
INFO_DATA2 = """{"uuid": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529", "filename": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "original_filename": "IMG_8440.JPG", "date": "2019-06-11T11:42:06.711805-07:00", "description": null, "title": null, "keywords": [], "labels": ["Sky", "Cloudy", "Fence", "Land", "Outdoor", "Park", "Amusement Park", "Roller Coaster"], "albums": [], "folders": {}, "persons": [], "path": "/Volumes/MacBook Catalina - Data/Users/rhet/Pictures/Photos Library.photoslibrary/originals/F/F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "ismissing": false, "hasadjustments": false, "external_edit": false, "favorite": false, "hidden": false, "latitude": 33.81558666666667, "longitude": -117.99298, "path_edited": null, "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": true, "incloud": true, "date_modified": "2019-10-14T00:51:47.141950-07:00", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Adventure City, Stanton, California, United States", "names": {"field0": [], "country": ["United States"], "state_province": ["California"], "sub_administrative_area": ["Orange"], "city": ["Stanton", "Anaheim", "Anaheim"], "field5": [], "additional_city_info": ["West Anaheim"], "ocean": [], "area_of_interest": ["Adventure City", "Adventure City"], "inland_water": [], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": []}, "country_code": "US", "ishome": false, "address_str": "Adventure City, 1240 S Beach Blvd, Anaheim, CA 92804, United States", "address": {"street": "1240 S Beach Blvd", "sub_locality": "West Anaheim", "city": "Stanton", "sub_administrative_area": "Orange", "state_province": "CA", "postal_code": "92804", "country": "United States", "iso_country_code": "US"}}, "exif": {"flash_fired": false, "iso": 25, "metering_mode": 5, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.0004940711462450593, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}""" INFO_DATA2 = """{"uuid": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529", "filename": "F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "original_filename": "IMG_8440.JPG", "date": "2019-06-11T11:42:06.711805-07:00", "description": null, "title": null, "keywords": [], "labels": ["Sky", "Cloudy", "Fence", "Land", "Outdoor", "Park", "Amusement Park", "Roller Coaster"], "albums": [], "folders": {}, "persons": [], "path": "/Volumes/MacBook Catalina - Data/Users/rhet/Pictures/Photos Library.photoslibrary/originals/F/F2BB3F98-90F0-4E4C-A09B-25C6822A4529.jpeg", "ismissing": false, "hasadjustments": false, "external_edit": false, "favorite": false, "hidden": false, "latitude": 33.81558666666667, "longitude": -117.99298, "path_edited": null, "shared": false, "isphoto": true, "ismovie": false, "uti": "public.jpeg", "burst": false, "live_photo": false, "path_live_photo": null, "iscloudasset": true, "incloud": true, "date_modified": "2019-10-14T00:51:47.141950-07:00", "portrait": false, "screenshot": false, "slow_mo": false, "time_lapse": false, "hdr": false, "selfie": false, "panorama": false, "has_raw": false, "uti_raw": null, "path_raw": null, "place": {"name": "Adventure City, Stanton, California, United States", "names": {"field0": [], "country": ["United States"], "state_province": ["California"], "sub_administrative_area": ["Orange"], "city": ["Stanton", "Anaheim", "Anaheim"], "field5": [], "additional_city_info": ["West Anaheim"], "ocean": [], "area_of_interest": ["Adventure City", "Adventure City"], "inland_water": [], "field10": [], "region": [], "sub_throughfare": [], "field13": [], "postal_code": [], "field15": [], "field16": [], "street_address": [], "body_of_water": []}, "country_code": "US", "ishome": false, "address_str": "Adventure City, 1240 S Beach Blvd, Anaheim, CA 92804, United States", "address": {"street": "1240 S Beach Blvd", "sub_locality": "West Anaheim", "city": "Stanton", "sub_administrative_area": "Orange", "state_province": "CA", "postal_code": "92804", "country": "United States", "iso_country_code": "US"}}, "exif": {"flash_fired": false, "iso": 25, "metering_mode": 5, "sample_rate": null, "track_format": null, "white_balance": 0, "aperture": 2.2, "bit_rate": null, "duration": null, "exposure_bias": 0.0, "focal_length": 4.15, "fps": null, "latitude": null, "longitude": null, "shutter_speed": 0.0004940711462450593, "camera_make": "Apple", "camera_model": "iPhone 6s", "codec": null, "lens_model": "iPhone 6s back camera 4.15mm f/2.2"}}"""
DATABASE_VERSION1 = "tests/export_db_version1.db" DATABASE_VERSION1 = "tests/export_db_version1.db"
@@ -41,6 +43,8 @@ def test_export_db():
assert db.get_stat_edited_for_file(filepath) == (10, 11, 12) assert db.get_stat_edited_for_file(filepath) == (10, 11, 12)
db.set_stat_converted_for_file(filepath, (7, 8, 9)) db.set_stat_converted_for_file(filepath, (7, 8, 9))
assert db.get_stat_converted_for_file(filepath) == (7, 8, 9) assert db.get_stat_converted_for_file(filepath) == (7, 8, 9)
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
assert db.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
# test set_data which sets all at the same time # test set_data which sets all at the same time
filepath2 = os.path.join(tempdir.name, "test2.jpg") filepath2 = os.path.join(tempdir.name, "test2.jpg")
@@ -109,6 +113,8 @@ def test_export_db_no_op():
assert db.get_stat_converted_for_file(filepath) is None assert db.get_stat_converted_for_file(filepath) is None
db.set_stat_edited_for_file(filepath, (10, 11, 12)) db.set_stat_edited_for_file(filepath, (10, 11, 12))
assert db.get_stat_edited_for_file(filepath) is None assert db.get_stat_edited_for_file(filepath) is None
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
assert db.get_sidecar_for_file(filepath) == (None, (None, None, None))
# test set_data which sets all at the same time # test set_data which sets all at the same time
filepath2 = os.path.join(tempdir.name, "test2.jpg") filepath2 = os.path.join(tempdir.name, "test2.jpg")
@@ -160,6 +166,7 @@ def test_export_db_in_memory():
db.set_stat_exif_for_file(filepath, (4, 5, 6)) db.set_stat_exif_for_file(filepath, (4, 5, 6))
db.set_stat_converted_for_file(filepath, (7, 8, 9)) db.set_stat_converted_for_file(filepath, (7, 8, 9))
db.set_stat_edited_for_file(filepath, (10, 11, 12)) db.set_stat_edited_for_file(filepath, (10, 11, 12))
db.set_sidecar_for_file(filepath, SIDECAR_DATA, (13, 14, 15))
db.close() db.close()
@@ -176,6 +183,7 @@ def test_export_db_in_memory():
assert dbram.get_stat_exif_for_file(filepath) == (4, 5, 6) assert dbram.get_stat_exif_for_file(filepath) == (4, 5, 6)
assert dbram.get_stat_converted_for_file(filepath) == (7, 8, 9) assert dbram.get_stat_converted_for_file(filepath) == (7, 8, 9)
assert dbram.get_stat_edited_for_file(filepath) == (10, 11, 12) assert dbram.get_stat_edited_for_file(filepath) == (10, 11, 12)
assert dbram.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
# change a value # change a value
dbram.set_uuid_for_file(filepath, "FUBAR") dbram.set_uuid_for_file(filepath, "FUBAR")
@@ -185,6 +193,7 @@ def test_export_db_in_memory():
dbram.set_stat_exif_for_file(filepath, (10, 11, 12)) dbram.set_stat_exif_for_file(filepath, (10, 11, 12))
dbram.set_stat_converted_for_file(filepath, (1, 2, 3)) dbram.set_stat_converted_for_file(filepath, (1, 2, 3))
dbram.set_stat_edited_for_file(filepath, (4, 5, 6)) dbram.set_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR" assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2 assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2
@@ -193,6 +202,7 @@ def test_export_db_in_memory():
assert dbram.get_stat_exif_for_file(filepath) == (10, 11, 12) assert dbram.get_stat_exif_for_file(filepath) == (10, 11, 12)
assert dbram.get_stat_converted_for_file(filepath) == (1, 2, 3) assert dbram.get_stat_converted_for_file(filepath) == (1, 2, 3)
assert dbram.get_stat_edited_for_file(filepath) == (4, 5, 6) assert dbram.get_stat_edited_for_file(filepath) == (4, 5, 6)
assert dbram.get_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
dbram.close() dbram.close()
@@ -205,6 +215,7 @@ def test_export_db_in_memory():
assert db.get_stat_exif_for_file(filepath) == (4, 5, 6) assert db.get_stat_exif_for_file(filepath) == (4, 5, 6)
assert db.get_stat_converted_for_file(filepath) == (7, 8, 9) assert db.get_stat_converted_for_file(filepath) == (7, 8, 9)
assert db.get_stat_edited_for_file(filepath) == (10, 11, 12) assert db.get_stat_edited_for_file(filepath) == (10, 11, 12)
assert db.get_sidecar_for_file(filepath) == (SIDECAR_DATA, (13, 14, 15))
assert db.get_info_for_uuid("FUBAR") is None assert db.get_info_for_uuid("FUBAR") is None
@@ -232,6 +243,7 @@ def test_export_db_in_memory_nofile():
dbram.set_stat_exif_for_file(filepath, (10, 11, 12)) dbram.set_stat_exif_for_file(filepath, (10, 11, 12))
dbram.set_stat_converted_for_file(filepath, (1, 2, 3)) dbram.set_stat_converted_for_file(filepath, (1, 2, 3))
dbram.set_stat_edited_for_file(filepath, (4, 5, 6)) dbram.set_stat_edited_for_file(filepath, (4, 5, 6))
dbram.set_sidecar_for_file(filepath, "FUBAR", (20, 21, 22))
assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR" assert dbram.get_uuid_for_file(filepath_lower) == "FUBAR"
assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2 assert dbram.get_info_for_uuid("FUBAR") == INFO_DATA2
@@ -240,5 +252,6 @@ def test_export_db_in_memory_nofile():
assert dbram.get_stat_exif_for_file(filepath) == (10, 11, 12) assert dbram.get_stat_exif_for_file(filepath) == (10, 11, 12)
assert dbram.get_stat_converted_for_file(filepath) == (1, 2, 3) assert dbram.get_stat_converted_for_file(filepath) == (1, 2, 3)
assert dbram.get_stat_edited_for_file(filepath) == (4, 5, 6) assert dbram.get_stat_edited_for_file(filepath) == (4, 5, 6)
assert dbram.get_sidecar_for_file(filepath) == ("FUBAR", (20, 21, 22))
dbram.close() dbram.close()

View File

@@ -1,6 +1,13 @@
""" Test template.py """ """ Test template.py """
import pytest import pytest
from osxphotos.exiftool import get_exiftool_path
try:
exiftool = get_exiftool_path()
except:
exiftool = None
PHOTOS_DB_PLACES = ( PHOTOS_DB_PLACES = (
"./tests/Test-Places-Catalina-10_15_7.photoslibrary/database/photos.db" "./tests/Test-Places-Catalina-10_15_7.photoslibrary/database/photos.db"
) )
@@ -57,6 +64,29 @@ UUID_BOOL_VALUES = {"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15"}
# Boolean type values that render to False # Boolean type values that render to False
UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"} UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"}
# for exiftool template
UUID_EXIFTOOL = {
"A92D9C26-3A50-4197-9388-CB5F7DB9FA91": {
"{exiftool:EXIF:Make}": ["Canon"],
"{exiftool:EXIF:Model}": ["Canon PowerShot G10"],
"{exiftool:EXIF:Make}/{exiftool:EXIF:Model}": ["Canon/Canon PowerShot G10"],
"{exiftool:IPTC:Keywords,foo}": ["foo"],
},
"DC99FBDD-7A52-4100-A5BB-344131646C30": {
"{exiftool:IPTC:Keywords}": [
"England",
"London",
"London 2018",
"St. James's Park",
"UK",
"United Kingdom",
],
"{,+exiftool:IPTC:Keywords}": [
"England,London,London 2018,St. James's Park,UK,United Kingdom"
],
},
}
TEMPLATE_VALUES = { TEMPLATE_VALUES = {
"{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546", "{name}": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
"{original_name}": "IMG_1064", "{original_name}": "IMG_1064",
@@ -737,3 +767,15 @@ def test_expand_in_place_with_delim_single_value():
for template in TEMPLATE_VALUES_TITLE: for template in TEMPLATE_VALUES_TITLE:
rendered, _ = photo.render_template(template) rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(TEMPLATE_VALUES_TITLE[template]) assert sorted(rendered) == sorted(TEMPLATE_VALUES_TITLE[template])
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
def test_exiftool_template():
import osxphotos
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_7)
for uuid in UUID_EXIFTOOL:
photo = photosdb.get_photo(uuid)
for template in UUID_EXIFTOOL[uuid]:
rendered, _ = photo.render_template(template)
assert sorted(rendered) == sorted(UUID_EXIFTOOL[uuid][template])