Compare commits

...

357 Commits

Author SHA1 Message Date
Rhet Turnbull
be2e16769d added {place.country_code} to template system 2020-03-28 10:18:58 -07:00
Rhet Turnbull
b0456dc8e6 Update TOC 2020-03-28 10:02:08 -07:00
Rhet Turnbull
c8bd8ea2f3 Test library update 2020-03-28 09:59:05 -07:00
Rhet Turnbull
67a9a9e21b Template system now supports default values 2020-03-28 09:57:48 -07:00
Rhet Turnbull
427c4c0bc4 Replaced template renderer with regex-based renderer 2020-03-28 07:58:50 -07:00
Rhet Turnbull
f0d200435a Fixed comment 2020-03-28 07:58:03 -07:00
Rhet Turnbull
49de3ecd2e test library updates 2020-03-28 07:24:45 -07:00
Rhet Turnbull
c06dd4233f Added detailed place data in PlaceInfo.names 2020-03-28 07:24:17 -07:00
Rhet Turnbull
fd638427d0 added missing import 2020-03-27 20:49:38 -07:00
Rhet Turnbull
6fb8fe8142 test library update 2020-03-25 17:56:59 -07:00
Rhet Turnbull
69cc6ce680 Updated place name processing for Photos 4 2020-03-25 17:56:39 -07:00
Rhet Turnbull
dfc31ff15f Type fix in help text 2020-03-23 19:24:44 -07:00
Rhet Turnbull
707544752e Removed template functions pending re-work of that code 2020-03-23 17:55:33 -07:00
Rhet Turnbull
564a5073f1 Updated README.md to document template system 2020-03-22 14:15:08 -07:00
Rhet Turnbull
d769dde358 version bump 2020-03-22 13:07:34 -07:00
Rhet Turnbull
d066435e3d Updated pathvalidate calls 2020-03-22 13:04:00 -07:00
Rhet Turnbull
8f0307fc24 Updated example 2020-03-22 12:55:24 -07:00
Rhet Turnbull
908fead8a2 Added export_by_album.py to examples 2020-03-22 11:37:25 -07:00
Rhet Turnbull
072e894e56 Updated CHANGELOG.md 2020-03-22 09:54:45 -07:00
Rhet Turnbull
47e57ee98e Updated dependencies 2020-03-22 09:54:10 -07:00
Rhet Turnbull
e90d9c6e11 Test library updates 2020-03-22 09:46:15 -07:00
Rhet Turnbull
2feb0999b3 Initial version of templating system for CLI 2020-03-22 09:45:56 -07:00
Rhet Turnbull
d26ea0dccc Fixed unnecessary warning exporting .JPG to .jpeg 2020-03-22 09:02:10 -07:00
Rhet Turnbull
aeae1e0b8a Fixed unnecessary warning exporting .JPG to .jpeg 2020-03-22 08:53:54 -07:00
Rhet Turnbull
128a35d6c0 Updated README.md dependencies and related projects 2020-03-21 19:22:56 -07:00
Rhet Turnbull
57d9163090 Started adding hooks for processing moments 2020-03-21 18:43:41 -07:00
Rhet Turnbull
a236ed42c1 Updated comments 2020-03-21 18:11:09 -07:00
Rhet Turnbull
ad58b03f2d Added __str__ to place 2020-03-21 17:38:30 -07:00
Rhet Turnbull
066215621d Updated requirements.txt 2020-03-21 15:42:12 -07:00
Rhet Turnbull
7f0558e08b Updated requirements.txt 2020-03-21 15:40:23 -07:00
Rhet Turnbull
4441d071b3 Added python 3.8 2020-03-21 14:29:47 -07:00
Rhet Turnbull
9da7ad6dcc Updated requirements.txt 2020-03-21 14:09:35 -07:00
Rhet Turnbull
91f71df07d version bump 2020-03-21 13:45:43 -07:00
Rhet Turnbull
9e314bfaf4 Updated requirements.txt 2020-03-21 13:41:11 -07:00
Rhet Turnbull
0948b24821 Updated requirements.txt 2020-03-21 13:39:14 -07:00
Rhet Turnbull
4b951826db Updated requirements.txt 2020-03-21 13:28:27 -07:00
Rhet Turnbull
cda5f44693 Fixed requirements.txt for bplist2 2020-03-21 12:51:27 -07:00
Rhet Turnbull
960487f296 still trying to debug github actions fail 2020-03-21 11:21:38 -07:00
Rhet Turnbull
6ab1511b4f Added pycodestyle needed by bpylist2 2020-03-21 11:15:39 -07:00
Rhet Turnbull
b8da9765b8 Updated CHANGELOG.md 2020-03-21 11:09:41 -07:00
Rhet Turnbull
21547a8eaa Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-03-21 11:05:49 -07:00
Rhet Turnbull
23b26ed130 fixed version of bpylist2 2020-03-21 11:05:39 -07:00
Rhet Turnbull
92e5bdd2e9 Update pythonpackage.yml 2020-03-21 10:48:49 -07:00
Rhet Turnbull
a723881dd3 Removed flake8 2020-03-21 10:45:47 -07:00
Rhet Turnbull
b338b34d50 Added PhotoInfo.place for reverse geolocation data 2020-03-21 10:39:42 -07:00
Rhet Turnbull
816b98e617 Updated CHANGELOG.md 2020-03-15 10:15:58 -07:00
Rhet Turnbull
39ffef502c Updated README.md 2020-03-15 10:13:41 -07:00
Rhet Turnbull
1e08a7449e test library update 2020-03-15 10:09:09 -07:00
Rhet Turnbull
0940f039d3 Lots of work on export code 2020-03-15 10:08:56 -07:00
Rhet Turnbull
c11afbaa6e Updated docs 2020-03-14 20:54:53 -07:00
Rhet Turnbull
940fc33f11 test library update 2020-03-14 20:11:04 -07:00
Rhet Turnbull
8542e1a97f Working on export edited bug for issue #78 2020-03-14 20:07:05 -07:00
Rhet Turnbull
dd20b8d8ac Fixed download-missing to only download when actually missing 2020-03-14 13:40:15 -07:00
Rhet Turnbull
765a3d27c5 fixed pylint warning 2020-03-14 12:15:35 -07:00
Rhet Turnbull
b68f4c2b8b removed OBE TODO 2020-03-14 12:06:50 -07:00
Rhet Turnbull
cc9220e076 Updated CHANGELOG.md 2020-03-14 12:01:38 -07:00
Rhet Turnbull
e99391a68e test library updates 2020-03-14 12:00:59 -07:00
Rhet Turnbull
783e097da3 version bump 2020-03-14 09:13:04 -07:00
Rhet Turnbull
279ab36929 Added MANIFEST.in 2020-03-14 09:10:05 -07:00
Rhet Turnbull
1f13ba837f Fixed bug in --download-missing related to burst images 2020-03-14 08:54:46 -07:00
Rhet Turnbull
dc87194eec Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-03-14 07:13:30 -07:00
Rhet Turnbull
d32774f495 Moved util scripts to utils 2020-03-14 07:13:17 -07:00
Rhet Turnbull
7da02991cf Moved util scripts to utils 2020-03-14 07:11:19 -07:00
Rhet Turnbull
6f413c64d7 removed activate from --download-missing-photos Applescript, closes #69 2020-03-14 06:58:24 -07:00
Rhet Turnbull
2d7d0b86e0 Test library updates 2020-03-14 06:43:14 -07:00
Rhet Turnbull
acb6b9e72f test library update 2020-03-13 20:36:51 -07:00
Rhet Turnbull
f1ade92e98 Added media type specials to json and string output, closes #68 2020-03-12 20:11:59 -07:00
Rhet Turnbull
a27ce33473 README.md update 2020-03-10 22:12:47 -07:00
Rhet Turnbull
2b7d84a4d1 Added query/export options for special media types 2020-03-09 22:17:49 -07:00
Rhet Turnbull
92b405a166 Updated CHANGELOG.md 2020-03-08 13:04:39 -07:00
Rhet Turnbull
15d7ad538d Added media type specials, closes #60 2020-03-08 12:52:44 -07:00
Rhet Turnbull
1f8fd6e929 Updated README.md 2020-03-07 14:56:46 -08:00
Rhet Turnbull
08a9793651 Updated CHANGELOG.md 2020-03-07 14:53:24 -08:00
Rhet Turnbull
2c8fc9789f Added check for exiftool in path 2020-03-07 14:50:16 -08:00
Rhet Turnbull
dbededcd0e Test database update 2020-03-07 14:37:29 -08:00
Rhet Turnbull
ef799610ae Added --exiftool to CLI export 2020-03-07 14:37:11 -08:00
Rhet Turnbull
8dea41961b Added exiftool 2020-03-07 09:50:30 -08:00
Rhet Turnbull
5799afbdc1 Updated TODO 2020-03-07 09:13:48 -08:00
Rhet Turnbull
9a0fc0db3e Updated test library 2020-03-07 09:13:09 -08:00
Rhet Turnbull
549170fa36 test library updates 2020-02-09 09:30:15 -08:00
Rhet Turnbull
dede640ef3 test library updates 2020-02-08 07:37:31 -08:00
Rhet Turnbull
2b3491bdc4 Updated CHANGELOG.md 2020-02-08 07:34:51 -08:00
Rhet Turnbull
e3c40bcbaa Cleaned up comments and unneeded test code 2020-02-08 07:28:47 -08:00
Rhet Turnbull
69addc3464 removed commented out code 2020-02-07 22:26:11 -08:00
Rhet Turnbull
c654e3dc61 Fixed bug in --download-missing to fix issue #64 2020-02-07 22:20:05 -08:00
Rhet Turnbull
1e013b6802 Updated CHANGELOG.md 2020-02-01 08:25:36 -08:00
Rhet Turnbull
640471eba9 Updated README.md 2020-02-01 08:22:30 -08:00
Rhet Turnbull
c346003059 Updated README.md 2020-02-01 08:19:58 -08:00
Rhet Turnbull
46d3c7dbda Added PhotosDB() behavior to open last library if no args passed but also added cautionary note to README 2020-02-01 08:16:20 -08:00
Rhet Turnbull
476e094365 cleanup 2020-02-01 07:48:30 -08:00
Rhet Turnbull
0bb579ee87 Updated help strings 2020-02-01 07:46:30 -08:00
Rhet Turnbull
91d5729bea Slight refactor to PhotosDB.photos() 2020-02-01 07:35:55 -08:00
Rhet Turnbull
fdf636ac88 Updated photos_repl.py 2020-02-01 07:09:36 -08:00
Rhet Turnbull
b6fe2b55e0 Updated documentation 2020-01-31 20:15:52 -08:00
Rhet Turnbull
6e563e214c Test library updates 2020-01-30 05:40:24 -08:00
Rhet Turnbull
ac8be51156 Updated PhotosDB to only copy database if locked, speed improvement for cases where DB not locked; closes #34 2020-01-30 05:38:11 -08:00
Rhet Turnbull
27994c9fd3 Removed _tmp_file code that's no longer needed 2020-01-29 05:33:47 -08:00
Rhet Turnbull
b9c360cd20 Updated _open_sql_file to use URI and read-only mode 2020-01-28 22:20:29 -08:00
Rhet Turnbull
f50cdd5403 Changed temp file handling to use tempfile.TemporaryDirectory, closes #59 2020-01-28 05:20:17 -08:00
Rhet Turnbull
1c792a371f Updated test for bad db 2020-01-28 05:08:53 -08:00
Rhet Turnbull
8e11e237ef Documentation update 2020-01-26 21:38:14 -08:00
Rhet Turnbull
675867a3d3 Updated README.md Dependencies 2020-01-26 21:26:24 -08:00
Rhet Turnbull
f910124fe1 Updated CHANGELOG.md 2020-01-26 20:22:56 -08:00
Rhet Turnbull
e79cb92693 Updated CLI options with more descriptive metavar names 2020-01-26 20:16:51 -08:00
Rhet Turnbull
f7c8b457a5 Added XMP sidecar option to export, closes #51 2020-01-26 19:58:56 -08:00
Rhet Turnbull
4dfb131a21 Added XMP sidecar to export 2020-01-26 09:34:53 -08:00
Rhet Turnbull
ba224af3fb Added Subject to JSON sidecar to match info Photo exports in XMP 2020-01-26 08:09:17 -08:00
Rhet Turnbull
4a1adaa156 Test library updates, closes #52 2020-01-25 10:25:06 -08:00
Rhet Turnbull
9ee96c55e0 changed a few warnings to debug messages for missing paths 2020-01-25 08:40:42 -08:00
Rhet Turnbull
1261425d00 Bug fixes for path_live_photo and lastmodifieddate 2020-01-25 08:37:49 -08:00
Rhet Turnbull
06cefcc7d2 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-01-25 08:10:41 -08:00
Rhet Turnbull
67b0ae0bf6 Added date_modified to PhotoInfo 2020-01-25 08:10:27 -08:00
Rhet Turnbull
4d36b3b31f Added date_modified to PhotoInfo 2020-01-25 08:07:50 -08:00
Rhet Turnbull
fd8d466e05 Test DB updates 2020-01-22 22:12:39 -08:00
Rhet Turnbull
898d3afc08 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-01-22 22:10:10 -08:00
Rhet Turnbull
a0fd52deea Merge pull request #58 from hshore29/master
Corrected Panorama Flag
2020-01-22 22:08:52 -08:00
hshore29
9ce4566d0f Panorama from CustomRenderedValue 2020-01-22 08:43:35 -05:00
hshore29
46c6b6d130 Merge pull request #1 from RhetTbull/master
Jan 20 Updates
2020-01-21 22:03:07 -05:00
Rhet Turnbull
ab1d95e458 Added instructions to photos_repl.py 2020-01-20 22:05:29 -08:00
Rhet Turnbull
db5effde52 Added photos_repl.py to examples 2020-01-20 08:59:52 -08:00
Rhet Turnbull
50b7e6920a CLI now looks for photos library to use if non specified by user 2020-01-20 08:26:32 -08:00
Rhet Turnbull
d37e6d9725 Updated CHANGELOG.md 2020-01-20 08:05:32 -08:00
Rhet Turnbull
0aff83ff21 Updated README.md 2020-01-20 08:02:03 -08:00
Rhet Turnbull
d1afd55a7c Updated README.md 2020-01-20 07:58:03 -08:00
Rhet Turnbull
2908a6c3a7 Updated docs 2020-01-20 07:55:13 -08:00
Rhet Turnbull
c7d11d410f Merge pull request #57 from mwort/from-to-date-query
Add --from-date and --to-date to query and export command
2020-01-20 07:36:35 -08:00
mwort
cfa2b4a828 Implement from_date and to_date in PhotosDB as well as query and export command. Some refactoring of CLI as well. 2020-01-20 14:04:50 +01:00
Rhet Turnbull
f1e872401c Cleaned up comments 2020-01-19 22:21:46 -08:00
Rhet Turnbull
bed7378039 Added CLI test for export 2020-01-19 21:55:13 -08:00
Rhet Turnbull
f0b18c3d29 Started adding tests for CLI 2020-01-19 21:27:40 -08:00
Rhet Turnbull
b9dee4995c Refactored _query. Still hairy, but less so. 2020-01-19 19:50:34 -08:00
Rhet Turnbull
7150956a48 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-01-19 18:08:35 -08:00
Rhet Turnbull
b544e2f171 version bump 2020-01-19 18:08:21 -08:00
Rhet Turnbull
216d0ca0f7 Merge pull request #55 from mwort/refactor-cli
Refactor CLI
2020-01-19 18:07:15 -08:00
mwort
a2067709cc Fix json_ argument to cli. 2020-01-20 01:09:55 +01:00
mwort
6e364fc9d9 Write out list of libraries to stderr except in list command. 2020-01-20 00:56:23 +01:00
mwort
e214746063 Refactor cli: singular --db, --json and query options. 2020-01-20 00:55:43 +01:00
Rhet Turnbull
694606e1a7 Updated CHANGELOG.md 2020-01-18 08:18:11 -08:00
Rhet Turnbull
57aaa4eeb7 version bump 2020-01-18 08:15:22 -08:00
Rhet Turnbull
a8934d24be Fixed CLI help to show correct command name in help text 2020-01-18 08:14:56 -08:00
Rhet Turnbull
44ac9d3c0c Updated CHANGELOG 2020-01-17 16:36:45 -08:00
Rhet Turnbull
ede56ffc31 Refactored PhotosDB and CLI to require explicity passing the database to avoid non-deterministic behavior when last database can't be found. This may break existing code. 2020-01-17 16:32:10 -08:00
Rhet Turnbull
ba1a2b32ad fixed bug in debug string 2020-01-17 16:27:48 -08:00
Rhet Turnbull
646ea4f24c Changed get_system_library_path to return None if could not get system library 2020-01-17 15:31:07 -08:00
Rhet Turnbull
99d3069530 removed unneeded comment 2020-01-14 21:46:00 -08:00
Rhet Turnbull
de05323a15 Fix to setup to specify versions of required packages 2020-01-14 05:28:49 -08:00
Rhet Turnbull
bd20388778 Updated CHANGELOG.md 2020-01-12 21:04:44 -08:00
Rhet Turnbull
146d54197f README.md update 2020-01-12 19:45:01 -08:00
Rhet Turnbull
f3674ef58b Updated README 2020-01-12 08:47:42 -08:00
Rhet Turnbull
a2dd648c89 Added download-missing option to CLI export 2020-01-12 08:46:57 -08:00
Rhet Turnbull
66cabf1af2 Added cloudasset/incloud options to CLI query 2020-01-12 07:42:30 -08:00
Rhet Turnbull
11bc008a91 Test library updates 2020-01-12 07:08:14 -08:00
Rhet Turnbull
1015ca34b6 Update README.md 2020-01-12 00:15:45 -08:00
Rhet Turnbull
af52d8710c Test library updates 2020-01-12 00:06:30 -08:00
Rhet Turnbull
58b76493ed Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-01-12 00:05:24 -08:00
Rhet Turnbull
edb31f796a Fixed search for edited photo in path_edited 2020-01-12 00:02:24 -08:00
Rhet Turnbull
e5747094e5 Update README.md 2020-01-11 11:11:41 -08:00
Rhet Turnbull
e089d135d3 Added incloud and iscloudasset for Photos 4 2020-01-11 11:09:20 -08:00
Rhet Turnbull
ff96448dee Updated README 2020-01-11 08:26:01 -08:00
Rhet Turnbull
78b5f1a19d Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-01-11 08:23:10 -08:00
Rhet Turnbull
f0712a7c06 version bump 2020-01-11 08:23:04 -08:00
Rhet Turnbull
53ac45af50 Removed python 3.8 2020-01-11 08:19:54 -08:00
Rhet Turnbull
9fd2e6d6d5 Added python 3.8 2020-01-11 08:16:07 -08:00
Rhet Turnbull
51a3adf169 Updated workflow to run on macos-latest 2020-01-11 08:14:17 -08:00
Rhet Turnbull
c5e1208c1c Updated pythonpackage.yml to run on Catalina 2020-01-11 08:13:05 -08:00
Rhet Turnbull
24b43b5e4d Added incloud and iscloudasset to PhotoInfo (Photos 5) 2020-01-11 08:10:28 -08:00
Rhet Turnbull
5473f3b3fd Added tests for live photos 2020-01-11 07:28:48 -08:00
Rhet Turnbull
9b5a1a64b0 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-01-11 06:37:53 -08:00
Rhet Turnbull
e39936c2dc Test library / database updates 2020-01-11 06:37:41 -08:00
Rhet Turnbull
2860ccf7d5 Update README.md 2020-01-10 20:50:59 -08:00
Rhet Turnbull
fcc0e1d083 Added text version of applescripts 2020-01-09 21:54:24 -08:00
Rhet Turnbull
0dac64409b Fixed bug in CLI export_photo -- live photo not exported if verbose wasn't set 2020-01-05 18:36:58 -08:00
Rhet Turnbull
f484737940 Initial code in place to determine selfies 2020-01-05 08:46:26 -08:00
Rhet Turnbull
eacd2ab12c Changed naming for exported live photos to improve re-import to Photos 2020-01-04 20:36:07 -08:00
Rhet Turnbull
1b7823e826 Updated CHANGELOG.md 2020-01-04 10:24:02 -08:00
Rhet Turnbull
6f6d37ceac Added live-photo option to CLI query and export 2020-01-04 10:15:56 -08:00
Rhet Turnbull
5099fd7715 Check that path exists in tests 2020-01-04 09:08:33 -08:00
Rhet Turnbull
d5eaff02f2 Added live photo support for both Photos 4 & 5 2020-01-04 09:07:23 -08:00
Rhet Turnbull
9fb05e4dd1 Added get_photo_info applescript for testing 2020-01-02 18:10:21 -08:00
Rhet Turnbull
1a89a18a01 Initial support for live photos (Photos 5 only) 2020-01-02 09:26:44 -08:00
Rhet Turnbull
996b8285cf Test database updates 2019-12-31 22:25:17 -08:00
Rhet Turnbull
00ecb7fea8 Added debug scripts 2019-12-31 21:32:08 -08:00
Rhet Turnbull
962052bc33 Updated CHANGELOG.md 2019-12-31 21:26:18 -08:00
Rhet Turnbull
2772bbff74 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-12-31 21:23:31 -08:00
Rhet Turnbull
593983a099 Added support for burst photos; added export-bursts to CLI 2019-12-31 21:18:16 -08:00
Rhet Turnbull
1136f84d9b Added support for bust photos; added export-bursts to CLI 2019-12-31 21:14:53 -08:00
Rhet Turnbull
2e1a8d2500 Added burst and burst_photos for Photos 5 2019-12-31 16:24:08 -08:00
Rhet Turnbull
05dea3afae Added hidden_photo_count to CLI 2019-12-31 15:24:22 -08:00
Rhet Turnbull
a550ba00d6 Temporary fix to filter out unselected burst photos 2019-12-31 08:26:00 -08:00
Rhet Turnbull
2ec29f26e7 Moved some SQL statements to multi-line for easier debugging 2019-12-31 07:46:36 -08:00
Rhet Turnbull
f493ca4b7f Fix to CLI info to correct number of photos/shared photos reported 2019-12-31 06:45:33 -08:00
Rhet Turnbull
d589fcb9b1 Added testing applescript to tests 2019-12-30 21:27:06 -08:00
Rhet Turnbull
980f62bdd5 Updated test database 2019-12-30 20:24:47 -08:00
Rhet Turnbull
10b59706be Test database updates 2019-12-30 06:21:34 -08:00
Rhet Turnbull
ac6bf019b2 Test database updates 2019-12-30 06:17:33 -08:00
Rhet Turnbull
f2d3741496 Update README.md 2019-12-29 10:46:28 -08:00
Rhet Turnbull
4a14287171 Update README.md 2019-12-29 10:35:34 -08:00
Rhet Turnbull
d3f1bf7b1c Added -V option for verbose to CLI export 2019-12-29 08:40:13 -08:00
Rhet Turnbull
9cd5363a80 Added support for filtering only movies or photos to CLI; added search for UTI to CLI 2019-12-29 08:37:38 -08:00
Rhet Turnbull
bfcab0c4fe Added CHANGELOG.md 2019-12-29 01:16:58 -08:00
Rhet Turnbull
51843fb46d Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-12-29 01:11:47 -08:00
Rhet Turnbull
6f4d129f07 Added support for movies for Photos 5; fixed bugs in ismissing and path 2019-12-29 01:11:18 -08:00
Rhet Turnbull
b030966051 Added support for movies for Photos 5; fixed bugs in ismissing and path 2019-12-29 01:04:20 -08:00
Rhet Turnbull
131dff4ea5 Updated test-images 2019-12-28 13:40:08 -08:00
Rhet Turnbull
dbe363e4d7 Initial support for movies 2019-12-28 13:36:36 -08:00
Rhet Turnbull
b4351d4d2f Cleaned up process_database5, removed unneeded SQL queries 2019-12-28 08:23:28 -08:00
Rhet Turnbull
8f67b45070 Cleaned up process_database4 2019-12-27 22:27:44 -08:00
Rhet Turnbull
815d7fda75 update logging messages and doc strings 2019-12-27 21:40:37 -08:00
Rhet Turnbull
7905a6c1fe Added tests for utils 2019-12-27 17:04:23 -08:00
Rhet Turnbull
e4d700fcff Added tests for PhotoInfo.__repr__ 2019-12-27 16:36:15 -08:00
Rhet Turnbull
588ba4b9cb Version bump 2019-12-27 16:06:43 -08:00
Rhet Turnbull
db3416903e Fixed bug in PhotosDB.__repr__ and added test for empty database 2019-12-27 16:05:49 -08:00
Rhet Turnbull
5bec99eac9 Added shared to json and str, updated __repr__ 2019-12-27 07:26:13 -08:00
Rhet Turnbull
0429c8f888 Cleaned up unused applescript code 2019-12-27 07:09:14 -08:00
Rhet Turnbull
55974f20be test DB updates 2019-12-27 06:54:46 -08:00
Rhet Turnbull
a207f3558c updated requirements.txt 2019-12-26 23:05:10 -08:00
Rhet Turnbull
0271b8ad9d Added --shared/--not-shared to CLI 2019-12-26 22:46:32 -08:00
Rhet Turnbull
6d20e9e361 Added test cases and documentation for shared photos and shared albums 2019-12-26 22:13:25 -08:00
Rhet Turnbull
10284d6589 Initial shared photos code for Photos 5 2019-12-26 00:18:29 -08:00
Rhet Turnbull
abc628bf8a temp fix for shared photos and ismissing 2019-12-25 22:27:53 -08:00
Rhet Turnbull
d7f50be622 version bump 2019-12-25 21:20:30 -08:00
Rhet Turnbull
e662e8aa02 temporary fix for missing path on shared photos 2019-12-25 21:19:42 -08:00
Rhet Turnbull
5fd02a5f74 Updated PhotoInfo.json to use isoformat for dates 2019-12-25 20:04:21 -08:00
Rhet Turnbull
2917bb78ff Added debugging info to process_database4/5 2019-12-25 12:13:49 -08:00
Rhet Turnbull
c199e45245 Updated _cleanup_tmp_files to fix error during testing 2019-12-25 01:15:03 -08:00
Rhet Turnbull
4ce09332c0 version bump 2019-12-25 00:29:49 -08:00
Rhet Turnbull
aee52e4cb9 Fixed missing import in photoinfo.py 2019-12-25 00:29:14 -08:00
Rhet Turnbull
098159466e Update README.md 2019-12-24 11:40:33 -08:00
Rhet Turnbull
8682d56050 Test database updates 2019-12-24 08:58:57 -08:00
Rhet Turnbull
ce50b4f893 Fix to export logic for missing photos 2019-12-24 08:57:59 -08:00
Rhet Turnbull
ff260dc072 Added check for missing file in export_photo 2019-12-24 08:33:54 -08:00
Rhet Turnbull
a81c19a01a Updated README.md 2019-12-24 08:18:27 -08:00
Rhet Turnbull
b2a1c53d04 ran black 2019-12-22 20:45:58 -08:00
Rhet Turnbull
40167d7a67 Added --original-name to export 2019-12-22 19:50:31 -08:00
Rhet Turnbull
57485247fc Bumped version number 2019-12-22 13:02:30 -08:00
Rhet Turnbull
57f6a282d6 Added --export-edited to export 2019-12-22 13:00:03 -08:00
Rhet Turnbull
048e80599e Updated README 2019-12-22 10:47:29 -08:00
Rhet Turnbull
d73f6651f9 Test database updates 2019-12-22 10:45:23 -08:00
Rhet Turnbull
2519104928 Initial version of export added to command line 2019-12-22 10:43:45 -08:00
Rhet Turnbull
8a00318399 Updated README 2019-12-22 08:46:05 -08:00
Rhet Turnbull
9cdeeb389c Fixed bug in query related to refactoring 2019-12-22 08:35:47 -08:00
Rhet Turnbull
2a5f0a2299 Added --json to dump command 2019-12-22 08:15:21 -08:00
Rhet Turnbull
8ee8a38f0f fixed bug related to db_path properties re-factoring 2019-12-21 22:53:22 -08:00
Rhet Turnbull
2906773ba1 Updated README for sidecar usage 2019-12-21 22:18:15 -08:00
Rhet Turnbull
f643e79afd Added sidecar option to PhotoInfo.export() 2019-12-21 22:10:38 -08:00
Rhet Turnbull
e5c50fa944 Removed python3.8 -- pyobjc fails to run 2019-12-21 11:00:17 -08:00
Rhet Turnbull
9fcc7379e6 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-12-21 10:09:24 -08:00
Rhet Turnbull
d95acdf9f8 Moved PhotosDB attributes to properties instead of methods 2019-12-21 10:08:49 -08:00
Rhet Turnbull
1ddd90cbdc Refactored PhotoInfo to use properties instead of methods--major update 2019-12-21 09:38:54 -08:00
Rhet Turnbull
4f449087a6 Added python3.8 to workflow 2019-12-21 08:32:34 -08:00
Rhet Turnbull
2dc7bccfb7 Updated README 2019-12-21 08:30:39 -08:00
Rhet Turnbull
190adea3fc Renamed cmd_line so python3 -m osxphotos will work 2019-12-21 08:19:32 -08:00
Rhet Turnbull
4ac9c1a7a8 Updated doc strings 2019-12-21 08:12:34 -08:00
Rhet Turnbull
b794e226e3 Restructured entire code base to make it easier to maintain. Closes #16 2019-12-21 08:06:25 -08:00
Rhet Turnbull
cd51782ef2 test db update 2019-12-21 08:03:44 -08:00
Rhet Turnbull
18395933a5 removed old applescript code and files 2019-12-21 06:59:02 -08:00
Rhet Turnbull
591db8b5a6 Updated sidecar tests 2019-12-16 21:55:59 -08:00
Rhet Turnbull
eb7ec9b5c6 added alpha version of exiftool_json_sidecar to export() 2019-12-15 21:12:25 -08:00
Rhet Turnbull
1fe885962e changed interface for export, prepped for exiftool_json_sidecar 2019-12-15 19:21:04 -08:00
Rhet Turnbull
b35e9d73ab Updated TOC in README 2019-12-14 12:39:17 -08:00
Rhet Turnbull
c7b2b233e9 Added TOC to README; closes #24 2019-12-14 12:33:59 -08:00
Rhet Turnbull
bea1683b94 Updated exception handling in PhotosDB.__init__() 2019-12-14 10:55:11 -08:00
Rhet Turnbull
bf8aed69cf Updated export example 2019-12-14 10:35:39 -08:00
Rhet Turnbull
800daf3658 Added PhotoInfo.export(); closes #10 2019-12-14 10:29:06 -08:00
Rhet Turnbull
d5a5bd41b3 refactored private vars in PhotoInfo 2019-12-09 21:45:50 -08:00
Rhet Turnbull
911804317b Updated PhotosDB.__init__() to accept positional or named arg for dbfile and added associated tests 2019-12-08 17:20:51 -08:00
Rhet Turnbull
aaba5cabf3 Added list option to cmd_line. Closes #14 2019-12-08 09:14:48 -08:00
Rhet Turnbull
7fef67f852 list_photo_libraries now searches entire disk 2019-12-08 00:38:58 -08:00
Rhet Turnbull
62fedc7fbf added list_photo_libraries 2019-12-08 00:24:34 -08:00
Rhet Turnbull
1d006a4b50 Added get_db_path and get_library_path to PhotosDB 2019-12-07 23:54:55 -08:00
Rhet Turnbull
2cedaedebb Added get_system_library_path 2019-12-07 23:10:36 -08:00
Rhet Turnbull
22d747ebab updated test library 2019-12-07 22:38:43 -08:00
Rhet Turnbull
c1c7b0092d removed TODO 2019-12-07 21:04:43 -08:00
Rhet Turnbull
0220b0eaff refactored code for unknown persons in Photos 5 2019-12-07 21:03:48 -08:00
Rhet Turnbull
d22affebd7 Updated test for unknown persons 2019-12-07 20:58:14 -08:00
Rhet Turnbull
da320e4f56 Handle blank persons in Photos 5 2019-12-07 20:57:23 -08:00
Rhet Turnbull
591336673a Fixed cmd_line so it doesn't create PhotosDB object unless needed 2019-12-07 20:44:15 -08:00
Rhet Turnbull
906b6c0911 added edited and external_edit to cmd_line and __str__, to_json; closes #12 2019-12-07 20:32:23 -08:00
Rhet Turnbull
f21c7c8b08 Removed applescript to close Photos 2019-12-07 19:35:42 -08:00
Rhet Turnbull
1ea83e6c8e updated test library 2019-12-07 19:22:07 -08:00
Rhet Turnbull
45da323840 README update 2019-12-07 16:20:23 -08:00
Rhet Turnbull
def6f8cdfe added --version to cmd_line 2019-12-07 15:56:46 -08:00
Rhet Turnbull
6e45cf9591 added _version.py 2019-12-07 15:34:49 -08:00
Rhet Turnbull
7fb0bad6be Added test for duplicate albums on 10.14.6 2019-12-07 15:10:34 -08:00
Rhet Turnbull
811946018d Fixed warning message for duplicate edit_resource_id 2019-12-07 13:13:34 -08:00
Rhet Turnbull
2a0f27ca57 Supports duplicate album names (treated as single album) 2019-12-07 12:51:39 -08:00
Rhet Turnbull
ff4066c49c version bump 2019-12-07 10:36:23 -08:00
Rhet Turnbull
1cf3e4b954 Updated album code in process_database4 and process_database5 to use album uuid 2019-12-07 10:29:09 -08:00
Rhet Turnbull
0219a9b4da Updated doc strings 2019-12-07 09:08:28 -08:00
Rhet Turnbull
b3c798033c Cleaned up logic in cmd_line query(). Closes #17 2019-12-07 07:21:23 -08:00
Rhet Turnbull
9777e27e3a Added external_edit() to README 2019-12-01 08:14:25 -08:00
Rhet Turnbull
3a1ca343a6 Added external edit for Photos 4 2019-12-01 08:01:09 -08:00
Rhet Turnbull
42baa29c18 Added external_edit for Photos 5 2019-12-01 07:36:16 -08:00
Rhet Turnbull
6a2be3e7d9 Fixed typo in README 2019-11-30 23:14:51 -08:00
Rhet Turnbull
9c32d77b2c Added hidden --debug option to cmd_line 2019-11-30 23:08:49 -08:00
Rhet Turnbull
eb563ad297 Updated get_db_version and associated tests 2019-11-30 21:39:58 -08:00
Rhet Turnbull
d056b6f8c0 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-11-30 18:32:10 -08:00
Rhet Turnbull
5a1176ed86 README update 2019-11-30 18:31:45 -08:00
Rhet Turnbull
e77b8e8a0a README update 2019-11-30 12:22:39 -08:00
Rhet Turnbull
55a6b5bf1c version bump 2019-11-30 11:03:07 -08:00
Rhet Turnbull
37dfc1e151 Fixed path_edited() for Photos 4.0 2019-11-30 10:54:43 -08:00
Rhet Turnbull
01b2f4420f fixed typo 2019-11-29 08:48:35 -08:00
Rhet Turnbull
68eef42599 Added path_edited() for Photos 5, still needs to be added for Photos <= 4.0 2019-11-29 08:47:11 -08:00
Rhet Turnbull
3dc0943453 cleaned up commented out code 2019-11-29 06:56:33 -08:00
Rhet Turnbull
7818fe0fcf Now copy write-ahead log and shared memory files which fixes problem with access to recently changed data 2019-11-28 09:25:22 -08:00
Rhet Turnbull
792fff13b8 added hasadjustments and tests for Photo5 2019-11-27 22:37:42 -08:00
Rhet Turnbull
b2242da9b7 cleaned up test code 2019-11-27 21:37:49 -08:00
Rhet Turnbull
1e5633efc8 TODO update 2019-11-27 07:42:44 -08:00
Rhet Turnbull
27b3469513 Added latitude, longitude to cmd_line 2019-11-27 07:39:48 -08:00
Rhet Turnbull
aa25c9eab7 Updated TODO 2019-11-27 07:32:11 -08:00
Rhet Turnbull
44321da243 Added location (latitude/longitude), closes issue #2 2019-11-27 07:27:49 -08:00
Rhet Turnbull
67127ef370 removed .jpg_originals that were added by mistake 2019-11-26 21:48:37 -08:00
Rhet Turnbull
51e720dce9 Added tests for hidden and favorite to both 14.6 and 15.1 2019-11-26 21:47:05 -08:00
Rhet Turnbull
45b8150d5f Added TODO 2019-11-25 21:25:21 -08:00
Rhet Turnbull
6ebacb7f38 README update 2019-11-25 20:57:48 -08:00
Rhet Turnbull
1b17608a02 Added TODOs 2019-11-24 21:24:02 -08:00
Rhet Turnbull
d4353d48bd Fixed bug in date() if imageTimeZoneOffsetSeconds was None 2019-11-24 21:12:26 -08:00
Rhet Turnbull
5af2b3e039 Added name and description to cmd_line 2019-11-24 21:08:01 -08:00
Rhet Turnbull
b36b7e7eb2 Added hidden/favorite/missing to cmd_line 2019-11-24 20:14:59 -08:00
Rhet Turnbull
99be93d88f version bump 2019-11-24 09:46:51 -08:00
Rhet Turnbull
d840c11ddb Added favorite and hidden to cmd_line 2019-11-24 09:45:36 -08:00
Rhet Turnbull
b1fd019120 Added hidden and favorite and test for 10.15 2019-11-24 09:39:36 -08:00
Rhet Turnbull
8b4a386c13 Fixed original_filename for older database versions 2019-11-24 08:46:02 -08:00
Rhet Turnbull
6abd10eddf removed old progress bar code 2019-11-24 08:35:30 -08:00
Rhet Turnbull
aa73c2f055 removed loguru code 2019-11-24 08:33:00 -08:00
Rhet Turnbull
966954340b version bump for release to pypi 2019-11-23 19:40:29 -08:00
Rhet Turnbull
7732708cb2 fixed command line to better handle detected but unidentified faces in Photos 5 2019-11-23 19:24:20 -08:00
Rhet Turnbull
a7d9f72372 Updated doc string 2019-11-23 19:06:52 -08:00
Rhet Turnbull
eabda3ea93 Turned off debug 2019-11-23 19:05:25 -08:00
Rhet Turnbull
a995a8d610 README update 2019-11-23 14:53:17 -08:00
Rhet Turnbull
ecd75c775d Added link to issues 2019-11-23 14:51:32 -08:00
Rhet Turnbull
ae7fc69b33 version bump 2019-11-23 14:49:13 -08:00
Rhet Turnbull
83186a655d Changed path() to return absolute path and fixed tests 2019-11-23 14:48:38 -08:00
Rhet Turnbull
243492df88 added test for 10.15/Catalina 2019-11-23 13:56:26 -08:00
Rhet Turnbull
986a092130 fixed == / != None 2019-11-23 13:27:54 -08:00
Rhet Turnbull
1c323338c6 version bump 2019-11-18 21:04:30 -08:00
Rhet Turnbull
b005f70133 fixed bug on 3.6 due to logging 2019-11-18 21:03:55 -08:00
Rhet Turnbull
9023a69073 Initial release for MacOS 10.15 / Photos 5 2019-11-17 18:01:03 -08:00
Rhet Turnbull
e7958c94e8 alpha version of filenames/paths 2019-11-17 17:19:33 -08:00
Rhet Turnbull
17bc04a24a replaced debug print statements with logging.debug 2019-11-17 16:46:34 -08:00
Rhet Turnbull
e0b1113870 replaced debug print statements with logging.debug 2019-11-17 16:45:46 -08:00
Rhet Turnbull
10f0cf1092 query will now run on Photos 5 2019-11-17 08:53:24 -08:00
Rhet Turnbull
a4b5f2a501 basic Photos 5 info now being read 2019-11-17 08:43:34 -08:00
Rhet Turnbull
02fcfbca2c More updates to _process_photos5 2019-11-16 11:01:04 -08:00
Rhet Turnbull
7eff015439 moved process_photos to process_photos4 and process_photos5 2019-11-16 08:18:22 -08:00
Rhet Turnbull
1c7e81b578 Fixed cleanup code 2019-11-16 07:59:59 -08:00
Rhet Turnbull
8649cda11f Fixed cleanup code 2019-11-16 07:59:04 -08:00
Rhet Turnbull
0c152c8c91 Updated some doc strings 2019-11-16 07:28:45 -08:00
Rhet Turnbull
b0a9e87d00 added _cleanup_tmp_files 2019-11-13 21:48:50 -08:00
Rhet Turnbull
fc6d7b1cf5 added comments/TODOs 2019-11-11 21:39:51 -08:00
Rhet Turnbull
5234567974 Moved db version check to function in prep for move to Photos 5 code 2019-11-11 21:30:51 -08:00
Rhet Turnbull
f62a9d3d4e fixed version check for Catalina 2019-10-14 09:44:11 -07:00
Rhet Turnbull
d311431c91 README update, added todo 2019-09-30 09:57:55 -07:00
Rhet Turnbull
572e32b71b version bump 2019-08-31 13:01:25 -07:00
Rhet Turnbull
3744a596b5 Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2019-08-31 13:00:29 -07:00
Rhet Turnbull
221df0029e Added pythonpackage.yml for CI workflow 2019-08-31 12:52:21 -07:00
Rhet Turnbull
a1038314e2 added todo 2019-08-24 08:36:57 -07:00
1456 changed files with 20647 additions and 3634 deletions

34
.github/workflows/pythonpackage.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Python package
on: [push]
jobs:
build:
runs-on: macOS-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.6, 3.7, 3.8]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint with flake8
run: |
# pip install flake8
# stop the build if there are Python syntax errors or undefined names
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pip install pytest
python -m pytest tests/

260
CHANGELOG.md Normal file
View File

@@ -0,0 +1,260 @@
### Changelog
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.23.3](https://github.com/RhetTbull/osxphotos/compare/v0.23.1...v0.23.3)
> 22 March 2020
- Initial version of templating system for CLI [`2feb099`](https://github.com/RhetTbull/osxphotos/commit/2feb0999b3f9ffd9a24e37238f780239a027aa49)
- Added __str__ to place [`ad58b03`](https://github.com/RhetTbull/osxphotos/commit/ad58b03f2d31daf33849b141570dd0fb5e0a262e)
- Test library updates [`e90d9c6`](https://github.com/RhetTbull/osxphotos/commit/e90d9c6e11fce7a4e4aa348dcc5f57420c0b6c44)
#### [v0.23.1](https://github.com/RhetTbull/osxphotos/compare/v0.23.0...v0.23.1)
> 21 March 2020
- Fixed requirements.txt for bplist2 [`cda5f44`](https://github.com/RhetTbull/osxphotos/commit/cda5f446933ea2272409d1f153e2a7811626ada6)
- Updated CHANGELOG.md [`b8da976`](https://github.com/RhetTbull/osxphotos/commit/b8da9765b8949eb90852d249c2877eeb1806d987)
- Updated requirements.txt [`9da7ad6`](https://github.com/RhetTbull/osxphotos/commit/9da7ad6dcc021fdafe358d74e1c52f69dc49ade8)
#### [v0.23.0](https://github.com/RhetTbull/osxphotos/compare/v0.22.23...v0.23.0)
> 21 March 2020
- Added PhotoInfo.place for reverse geolocation data [`b338b34`](https://github.com/RhetTbull/osxphotos/commit/b338b34d5055a7621e4ebe4fbbae12227d77af6d)
- Updated CHANGELOG.md [`816b98e`](https://github.com/RhetTbull/osxphotos/commit/816b98e617c30d0bdb51bc2413f9915742c8592e)
- Update pythonpackage.yml [`92e5bdd`](https://github.com/RhetTbull/osxphotos/commit/92e5bdd2e986e5de2a710abf60ba0dc99c6a6730)
#### [v0.22.23](https://github.com/RhetTbull/osxphotos/compare/v0.22.21...v0.22.23)
> 15 March 2020
- Lots of work on export code [`0940f03`](https://github.com/RhetTbull/osxphotos/commit/0940f039d3e628dc4f25c69bf27ce413807d3f71)
- test library update [`1e08a74`](https://github.com/RhetTbull/osxphotos/commit/1e08a7449e69965a37373dadabb37c993d93fc69)
#### [v0.22.21](https://github.com/RhetTbull/osxphotos/compare/v0.22.17...v0.22.21)
> 15 March 2020
- Working on export edited bug for issue #78 [`8542e1a`](https://github.com/RhetTbull/osxphotos/commit/8542e1a97f6b640f287b37af9e50fd05f964ec4d)
- Fixed download-missing to only download when actually missing [`dd20b8d`](https://github.com/RhetTbull/osxphotos/commit/dd20b8d8ac3b16d3b72a26b97dcc620b11e3a7c0)
- Updated CHANGELOG.md [`cc9220e`](https://github.com/RhetTbull/osxphotos/commit/cc9220e0763816d784f2fd8377dfe14a99981622)
#### [v0.22.17](https://github.com/RhetTbull/osxphotos/compare/v0.22.16...v0.22.17)
> 14 March 2020
- Added MANIFEST.in [`279ab36`](https://github.com/RhetTbull/osxphotos/commit/279ab369295cfe1c778b38e212248271e4fc659e)
- version bump [`783e097`](https://github.com/RhetTbull/osxphotos/commit/783e097da35a210a2aa5c75865a8599541b9da0b)
#### [v0.22.16](https://github.com/RhetTbull/osxphotos/compare/v0.22.13...v0.22.16)
> 14 March 2020
- removed activate from --download-missing-photos Applescript, closes #69 [`#69`](https://github.com/RhetTbull/osxphotos/issues/69)
- Added media type specials to json and string output, closes #68 [`#68`](https://github.com/RhetTbull/osxphotos/issues/68)
- Added query/export options for special media types [`2b7d84a`](https://github.com/RhetTbull/osxphotos/commit/2b7d84a4d103982ad874d875bafbc34d654d539a)
- README.md update [`a27ce33`](https://github.com/RhetTbull/osxphotos/commit/a27ce33473df3260dfb7ed26e28295cbf87d1e78)
- Test library updates [`2d7d0b8`](https://github.com/RhetTbull/osxphotos/commit/2d7d0b86e0008cae043e314937504f36ad882990)
- Fixed bug in --download-missing related to burst images [`1f13ba8`](https://github.com/RhetTbull/osxphotos/commit/1f13ba837fe36ff4eeb48cca02f5312a88a0a765)
- test library update [`acb6b9e`](https://github.com/RhetTbull/osxphotos/commit/acb6b9e72f7f6b8f4f1d64b46f270a4d3e984fef)
#### [v0.22.13](https://github.com/RhetTbull/osxphotos/compare/v0.22.12...v0.22.13)
> 8 March 2020
- Added media type specials, closes #60 [`#60`](https://github.com/RhetTbull/osxphotos/issues/60)
- Updated CHANGELOG.md [`08a9793`](https://github.com/RhetTbull/osxphotos/commit/08a9793651481e1984a4482794ffedd48e4367a2)
- Updated README.md [`1f8fd6e`](https://github.com/RhetTbull/osxphotos/commit/1f8fd6e929cc0edd3dd2f222416454d26955bf2a)
#### [v0.22.12](https://github.com/RhetTbull/osxphotos/compare/0.22.10...v0.22.12)
> 7 March 2020
- Added exiftool [`8dea419`](https://github.com/RhetTbull/osxphotos/commit/8dea41961bad285be7058a68e5f7199e5cfb740e)
- Added --exiftool to CLI export [`ef79961`](https://github.com/RhetTbull/osxphotos/commit/ef799610aea67b703a7d056b7eee227534ba78a5)
- Updated test library [`9a0fc0d`](https://github.com/RhetTbull/osxphotos/commit/9a0fc0db3e79359610fd0f124a97b03fcf97d8a7)
#### [0.22.10](https://github.com/RhetTbull/osxphotos/compare/v0.22.9...0.22.10)
> 8 February 2020
- Fixed bug in --download-missing to fix issue #64 [`c654e3d`](https://github.com/RhetTbull/osxphotos/commit/c654e3dc61283382b37b6892dab1516ec517143a)
- removed commented out code [`69addc3`](https://github.com/RhetTbull/osxphotos/commit/69addc34649f992c6a4a0e0e334754a72530f0ba)
- Updated CHANGELOG.md [`1e013b6`](https://github.com/RhetTbull/osxphotos/commit/1e013b6802e49e26ec5a94eb702e841b2eb68395)
#### [v0.22.9](https://github.com/RhetTbull/osxphotos/compare/v0.22.7...v0.22.9)
> 1 February 2020
- Updated PhotosDB to only copy database if locked, speed improvement for cases where DB not locked; closes #34 [`#34`](https://github.com/RhetTbull/osxphotos/issues/34)
- Changed temp file handling to use tempfile.TemporaryDirectory, closes #59 [`#59`](https://github.com/RhetTbull/osxphotos/issues/59)
- Slight refactor to PhotosDB.photos() [`91d5729`](https://github.com/RhetTbull/osxphotos/commit/91d5729beaa0f0c2583e6320b18d958429e66075)
- Test library updates [`6e563e2`](https://github.com/RhetTbull/osxphotos/commit/6e563e214c569ba7838f7464de9258c3bba5db23)
- Removed _tmp_file code that's no longer needed [`27994c9`](https://github.com/RhetTbull/osxphotos/commit/27994c9fd372303833a5794f1de9815f425c762e)
- Updated photos_repl.py [`fdf636a`](https://github.com/RhetTbull/osxphotos/commit/fdf636ac8864ebb2cc324b1f9d3c6c82ee3910f9)
- Updated CHANGELOG.md [`f910124`](https://github.com/RhetTbull/osxphotos/commit/f910124fe1fbf75d44c09c79607374bf000733a1)
#### [v0.22.7](https://github.com/RhetTbull/osxphotos/compare/v0.22.4...v0.22.7)
> 27 January 2020
- Corrected Panorama Flag [`#58`](https://github.com/RhetTbull/osxphotos/pull/58)
- Jan 20 Updates [`#1`](https://github.com/RhetTbull/osxphotos/pull/1)
- Added XMP sidecar option to export, closes #51 [`#51`](https://github.com/RhetTbull/osxphotos/issues/51)
- Test library updates, closes #52 [`#52`](https://github.com/RhetTbull/osxphotos/issues/52)
- Added XMP sidecar to export [`4dfb131`](https://github.com/RhetTbull/osxphotos/commit/4dfb131a21b1b1efefe3b918ecb06fc6fcb03f2c)
- Added date_modified to PhotoInfo [`67b0ae0`](https://github.com/RhetTbull/osxphotos/commit/67b0ae0bf679815372d415c3064e21d46a5b8718)
- Added date_modified to PhotoInfo [`4d36b3b`](https://github.com/RhetTbull/osxphotos/commit/4d36b3b31f3e0e74d9d111b6b691771e19f94086)
- Updated CLI options with more descriptive metavar names [`e79cb92`](https://github.com/RhetTbull/osxphotos/commit/e79cb92693758c984dc789d5fa5d2e87e381e921)
- CLI now looks for photos library to use if non specified by user [`50b7e69`](https://github.com/RhetTbull/osxphotos/commit/50b7e6920a694aa45f478d1131868525c9147919)
#### [v0.22.4](https://github.com/RhetTbull/osxphotos/compare/v0.22.0...v0.22.4)
> 20 January 2020
- Add --from-date and --to-date to query and export command [`#57`](https://github.com/RhetTbull/osxphotos/pull/57)
- Refactor CLI [`#55`](https://github.com/RhetTbull/osxphotos/pull/55)
- Refactor cli: singular --db, --json and query options. [`e214746`](https://github.com/RhetTbull/osxphotos/commit/e214746063271e6f9f586286103ed051ada49d85)
- Implement from_date and to_date in PhotosDB as well as query and export command. Some refactoring of CLI as well. [`cfa2b4a`](https://github.com/RhetTbull/osxphotos/commit/cfa2b4a828facf0aff5bc19f777457ad776c4a05)
- Refactored _query. Still hairy, but less so. [`b9dee49`](https://github.com/RhetTbull/osxphotos/commit/b9dee4995c6d89fadb3d2482374b7098f2ab5ed9)
- Updated README.md [`0aff83f`](https://github.com/RhetTbull/osxphotos/commit/0aff83ff21c20e293c0b75bacf2863090a0fb725)
- Started adding tests for CLI [`f0b18c3`](https://github.com/RhetTbull/osxphotos/commit/f0b18c3d29b2141d348be0495013c51c072c6251)
#### [v0.22.0](https://github.com/RhetTbull/osxphotos/compare/v0.21.5...v0.22.0)
> 18 January 2020
- Refactored PhotosDB and CLI to require explicity passing the database to avoid non-deterministic behavior when last database can't be found. This may break existing code. [`ede56ff`](https://github.com/RhetTbull/osxphotos/commit/ede56ffc31cf98811b3d4d16e22406ac0eae0315)
- Changed get_system_library_path to return None if could not get system library [`646ea4f`](https://github.com/RhetTbull/osxphotos/commit/646ea4f24ca1119b27280af1445e31adcd0690f0)
- Updated CHANGELOG.md [`bd20388`](https://github.com/RhetTbull/osxphotos/commit/bd20388778dfa645277029601c63fc9835b7a406)
#### [v0.21.5](https://github.com/RhetTbull/osxphotos/compare/v0.21.0...v0.21.5)
> 13 January 2020
- Fixed search for edited photo in path_edited [`edb31f7`](https://github.com/RhetTbull/osxphotos/commit/edb31f796a76912e6ed8182b691396cf4ec62ffa)
- Added tests for live photos [`5473f3b`](https://github.com/RhetTbull/osxphotos/commit/5473f3b3fd745d4772721dfd1ed821ab0660bf72)
- Added incloud and iscloudasset for Photos 4 [`e089d13`](https://github.com/RhetTbull/osxphotos/commit/e089d135d3e04320bf98b2c9b11875343e68be04)
#### [v0.21.0](https://github.com/RhetTbull/osxphotos/compare/v0.20.0...v0.21.0)
> 4 January 2020
- Added live photo support for both Photos 4 & 5 [`d5eaff0`](https://github.com/RhetTbull/osxphotos/commit/d5eaff02f2a29a9d105ab72e9a9aeffbc9a3425b)
- Added support for burst photos; added export-bursts to CLI [`593983a`](https://github.com/RhetTbull/osxphotos/commit/593983a09940e67fb9347bf345cfd7289465fa0a)
- Added live-photo option to CLI query and export [`6f6d37c`](https://github.com/RhetTbull/osxphotos/commit/6f6d37ceacf71a52a2c0216f0ad75afee244946a)
#### [v0.20.0](https://github.com/RhetTbull/osxphotos/compare/v0.19.0...v0.20.0)
> 1 January 2020
- Added support for filtering only movies or photos to CLI; added search for UTI to CLI [`9cd5363`](https://github.com/RhetTbull/osxphotos/commit/9cd5363a800dd85f333219788c661745b2ce88ad)
- Added support for bust photos; added export-bursts to CLI [`1136f84`](https://github.com/RhetTbull/osxphotos/commit/1136f84d9b5ea454115ba3d2720625722671e63b)
- Temporary fix to filter out unselected burst photos [`a550ba0`](https://github.com/RhetTbull/osxphotos/commit/a550ba00d6ff43a819cb18446e532f10ded81834)
#### [v0.19.0](https://github.com/RhetTbull/osxphotos/compare/v0.18.0...v0.19.0)
> 29 December 2019
- Added support for movies for Photos 5; fixed bugs in ismissing and path [`6f4d129`](https://github.com/RhetTbull/osxphotos/commit/6f4d129f07046c4a34d3d6cf6854c8514a594781)
- Added support for movies for Photos 5; fixed bugs in ismissing and path [`b030966`](https://github.com/RhetTbull/osxphotos/commit/b030966051af93be380ff967ac047bf566e5d817)
- Initial support for movies [`dbe363e`](https://github.com/RhetTbull/osxphotos/commit/dbe363e4d754253a0405fb1df045677e8780d630)
#### [v0.18.0](https://github.com/RhetTbull/osxphotos/compare/v0.15.1...v0.18.0)
> 27 December 2019
- Restructured entire code base to make it easier to maintain. Closes #16 [`#16`](https://github.com/RhetTbull/osxphotos/issues/16)
- Added TOC to README; closes #24 [`#24`](https://github.com/RhetTbull/osxphotos/issues/24)
- removed old applescript code and files [`1839593`](https://github.com/RhetTbull/osxphotos/commit/18395933a583314d5d992492713752003852e75c)
- Added test cases and documentation for shared photos and shared albums [`6d20e9e`](https://github.com/RhetTbull/osxphotos/commit/6d20e9e36185aa027d82237cadfe3b55614ba96f)
- Refactored PhotoInfo to use properties instead of methods--major update [`1ddd90c`](https://github.com/RhetTbull/osxphotos/commit/1ddd90cbdc824afc5df9d2347e730bd9f86350ee)
- Moved PhotosDB attributes to properties instead of methods [`d95acdf`](https://github.com/RhetTbull/osxphotos/commit/d95acdf9f8764a1720bcba71a6dad29bf668eaf9)
- changed interface for export, prepped for exiftool_json_sidecar [`1fe8859`](https://github.com/RhetTbull/osxphotos/commit/1fe885962e8a9a420e776bdd3dc640ca143224b2)
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.1)
> 14 December 2019
- Added PhotoInfo.export(); closes #10 [`#10`](https://github.com/RhetTbull/osxphotos/issues/10)
- refactored private vars in PhotoInfo [`d5a5bd4`](https://github.com/RhetTbull/osxphotos/commit/d5a5bd41b3d3e184d3f9a9d05a32a51fcbe1ef0a)
- Updated export example [`bf8aed6`](https://github.com/RhetTbull/osxphotos/commit/bf8aed69cfff61733e4cfd5ed2058bb20e3f5299)
#### [v0.14.21](https://github.com/RhetTbull/osxphotos/compare/v0.14.8...v0.14.21)
> 9 December 2019
- Added list option to cmd_line. Closes #14 [`#14`](https://github.com/RhetTbull/osxphotos/issues/14)
- added edited and external_edit to cmd_line and __str__, to_json; closes #12 [`#12`](https://github.com/RhetTbull/osxphotos/issues/12)
- Cleaned up logic in cmd_line query(). Closes #17 [`#17`](https://github.com/RhetTbull/osxphotos/issues/17)
- Added get_db_path and get_library_path to PhotosDB [`1d006a4`](https://github.com/RhetTbull/osxphotos/commit/1d006a4b50ed58b01c6116734bef5f740655a063)
- Updated PhotosDB.__init__() to accept positional or named arg for dbfile and added associated tests [`9118043`](https://github.com/RhetTbull/osxphotos/commit/911804317b98bf485a39b8588c772be14314aa51)
- Updated album code in process_database4 and process_database5 to use album uuid [`1cf3e4b`](https://github.com/RhetTbull/osxphotos/commit/1cf3e4b9540c15f8bda2545deb183912bcda40a7)
- Updated get_db_version and associated tests [`eb563ad`](https://github.com/RhetTbull/osxphotos/commit/eb563ad29738f29f3514ebfb4747baa2dc5356be)
- Added external_edit for Photos 5 [`42baa29`](https://github.com/RhetTbull/osxphotos/commit/42baa29c18fe2ff16e4d684f87ef7a85993898c1)
#### [v0.14.8](https://github.com/RhetTbull/osxphotos/compare/v0.14.6...v0.14.8)
> 30 November 2019
- Added path_edited() for Photos 5, still needs to be added for Photos &lt;= 4.0 [`68eef42`](https://github.com/RhetTbull/osxphotos/commit/68eef42599c737e180d2d0ead936630abd5a8a65)
- Fixed path_edited() for Photos 4.0 [`37dfc1e`](https://github.com/RhetTbull/osxphotos/commit/37dfc1e1513c93088fca7cc6def1219d32694468)
- cleaned up commented out code [`3dc0943`](https://github.com/RhetTbull/osxphotos/commit/3dc09434535b98a7989c2051a28ecf3ebdc772cc)
#### [v0.14.6](https://github.com/RhetTbull/osxphotos/compare/v0.14.4...v0.14.6)
> 28 November 2019
- Added tests for hidden and favorite to both 14.6 and 15.1 [`51e720d`](https://github.com/RhetTbull/osxphotos/commit/51e720dce9238c2a2b44a7ae956e40f0cd6452d7)
- Added location (latitude/longitude), closes issue #2 [`44321da`](https://github.com/RhetTbull/osxphotos/commit/44321da243e374c5239e9bcd28c3515e32e1076a)
- cleaned up test code [`b2242da`](https://github.com/RhetTbull/osxphotos/commit/b2242da9b7031f614c73be3fb5446a97f69b1d0d)
#### [v0.14.4](https://github.com/RhetTbull/osxphotos/compare/v0.14.0...v0.14.4)
> 25 November 2019
- Added name and description to cmd_line [`5af2b3e`](https://github.com/RhetTbull/osxphotos/commit/5af2b3e039e5e5a92b858592b8b968568d82e40f)
- removed loguru code [`aa73c2f`](https://github.com/RhetTbull/osxphotos/commit/aa73c2f0559de6bcdc521e9345e07898b36795bb)
- Added hidden/favorite/missing to cmd_line [`b36b7e7`](https://github.com/RhetTbull/osxphotos/commit/b36b7e7eb2a258f864f34363de4b6d9228ee6090)
#### [v0.14.0](https://github.com/RhetTbull/osxphotos/compare/v0.12.3...v0.14.0)
> 24 November 2019
- added test for 10.15/Catalina [`243492d`](https://github.com/RhetTbull/osxphotos/commit/243492df88409566c46cbc02ca01d509e711bcdd)
- moved process_photos to process_photos4 and process_photos5 [`7eff015`](https://github.com/RhetTbull/osxphotos/commit/7eff015439361f3f7be99777d878713afd10c480)
- basic Photos 5 info now being read [`a4b5f2a`](https://github.com/RhetTbull/osxphotos/commit/a4b5f2a501d9c98f9609de96757481f323b31ab0)
#### [v0.12.3](https://github.com/RhetTbull/osxphotos/compare/v0.12.2...v0.12.3)
> 24 August 2019
- fixed typo in README [`39ef8dd`](https://github.com/RhetTbull/osxphotos/commit/39ef8ddf3fdf8e9d22566c51783f9b78fab4f439)
#### [v0.12.2](https://github.com/RhetTbull/osxphotos/compare/v0.10.4-beta...v0.12.2)
> 24 August 2019
- Added tests for 10.14.6 [`fb2c12d`](https://github.com/RhetTbull/osxphotos/commit/fb2c12d9818fbec74f947638b1b60a2c3f73effb)
- Added support and tests for 10.12 [`58f5283`](https://github.com/RhetTbull/osxphotos/commit/58f52833d62672ed13fcfa16be5d999e75f37e2b)
- Added osxphotos command line tool [`0e65ab5`](https://github.com/RhetTbull/osxphotos/commit/0e65ab5452d96dc9913683d90d1fb2c833cd75b8)
#### [v0.10.4-beta](https://github.com/RhetTbull/osxphotos/compare/v0.10.1-alpha...v0.10.4-beta)
> 28 July 2019
- Added test for 10.14 mojave [`af122e9`](https://github.com/RhetTbull/osxphotos/commit/af122e9392d45387e302ebb79b28e045dd3fa61a)
- update requirements.txt [`81be373`](https://github.com/RhetTbull/osxphotos/commit/81be373505ad858ae8ef1196ccfb5e6f04bf6bfc)
- Updated README, added os & db version tests, updated test library for 10.13 [`a58ac14`](https://github.com/RhetTbull/osxphotos/commit/a58ac149f313ece99ff2d32a8c22e8b8b75eaebc)
#### v0.10.1-alpha
> 27 July 2019
- first commit [`8b61d57`](https://github.com/RhetTbull/osxphotos/commit/8b61d573ed4dbb3fd44b94ee265767b2011fcf90)
- Added tests [`3023f56`](https://github.com/RhetTbull/osxphotos/commit/3023f568b73733fb3dfbba4f519a7c2d1995784f)
- Updated README, added PhotoInfo.hasadjustments() [`9efa83c`](https://github.com/RhetTbull/osxphotos/commit/9efa83c5cd3f23cf681c459f73a466496c552396)

2
MANIFEST.in Normal file
View File

@@ -0,0 +1,2 @@
include README.md
include osxphotos/templates/*

1038
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,24 @@
import osxphotos
import os.path
def main():
photosdb = osxphotos.PhotosDB()
print(f"db file = {photosdb.get_db_path()}")
print(f"db version = {photosdb.get_db_version()}")
db = osxphotos.utils.get_system_library_path()
if db is None:
# Note: get_system_library_path only works on MacOS 10.15+
db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
print(photosdb.keywords())
print(photosdb.persons())
print(photosdb.albums())
photosdb = osxphotos.PhotosDB(db)
print(f"db file = {photosdb.db_path}")
print(f"db version = {photosdb.db_version}")
print(photosdb.keywords_as_dict())
print(photosdb.persons_as_dict())
print(photosdb.albums_as_dict())
print(photosdb.keywords)
print(photosdb.persons)
print(photosdb.albums)
print(photosdb.keywords_as_dict)
print(photosdb.persons_as_dict)
print(photosdb.albums_as_dict)
# find all photos with Keyword = Kids and containing person Katie
photos = photosdb.photos(keywords=["Kids"], persons=["Katie"])
@@ -29,17 +35,17 @@ def main():
photos = photosdb.photos()
for p in photos:
print(
p.uuid(),
p.filename(),
p.date(),
p.description(),
p.name(),
p.keywords(),
p.albums(),
p.persons(),
p.path(),
p.ismissing(),
p.hasadjustments(),
p.uuid,
p.filename,
p.date,
p.description,
p.title,
p.keywords,
p.albums,
p.persons,
p.path,
p.ismissing,
p.hasadjustments,
)

29
examples/export.py Normal file
View File

@@ -0,0 +1,29 @@
""" Export all photos to ~/Desktop/export
If file has been edited, export the edited version,
otherwise, export the original version """
import os.path
import osxphotos
def main():
db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
photosdb = osxphotos.PhotosDB(db)
photos = photosdb.photos()
export_path = os.path.expanduser("~/Desktop/export")
for p in photos:
if not p.ismissing:
if p.hasadjustments:
exported = p.export(export_path, edited=True)
else:
exported = p.export(export_path)
print(f"Exported {p.filename} to {exported}")
else:
print(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,75 @@
""" Export all photos to specified directory using album names as folders
If file has been edited, also export the edited version,
otherwise, export the original version
This will result in duplicate photos if photo is in more than album """
import os.path
import pathlib
import sys
import click
from pathvalidate import is_valid_filepath, sanitize_filepath
import osxphotos
@click.command()
@click.argument("export_path", type=click.Path(exists=True))
@click.option(
"--default-album",
help="Default folder for photos with no album. Defaults to 'unfiled'",
default="unfiled",
)
@click.option(
"--library-path",
help="Path to Photos library, default to last used library",
default=None,
)
def export(export_path, default_album, library_path):
export_path = os.path.expanduser(export_path)
library_path = os.path.expanduser(library_path) if library_path else None
if library_path is not None:
photosdb = osxphotos.PhotosDB(library_path)
else:
photosdb = osxphotos.PhotosDB()
photos = photosdb.photos()
for p in photos:
if not p.ismissing:
albums = p.albums
if not albums:
albums = [default_album]
for album in albums:
click.echo(f"exporting {p.filename} in album {album}")
# make sure no invalid characters in destination path (could be in album name)
album_name = sanitize_filepath(album, platform="auto")
# create destination folder, if necessary, based on album name
dest_dir = os.path.join(export_path, album_name)
# verify path is a valid path
if not is_valid_filepath(dest_dir, platform="auto"):
sys.exit(f"Invalid filepath {dest_dir}")
# create destination dir if needed
if not os.path.isdir(dest_dir):
os.makedirs(dest_dir)
# export the photo
if p.hasadjustments:
# export edited version
exported = p.export(dest_dir, edited=True)
edited_name = pathlib.Path(p.path_edited).name
click.echo(f"Exported {edited_name} to {exported}")
# export unedited version
exported = p.export(dest_dir)
click.echo(f"Exported {p.filename} to {exported}")
else:
click.echo(f"Skipping missing photo: {p.filename}")
if __name__ == "__main__":
export() # pylint: disable=no-value-for-parameter

48
examples/photos_repl.py Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3 -i
# Open an interactive REPL with photosdb and photos defined
# as osxphotos.PhotosDB() and PhotosDB.photos respectively
# useful for debugging or exploring the Photos database
# If you run this using python from command line, do so with -i flag:
# python3 -i examples/photos_repl.py
import sys
import time
# click needed since this uses a couple of functions from CLI (__main__.py)
import click
import osxphotos
from osxphotos.__main__ import get_photos_db, _list_libraries
def main():
db = None
if len(sys.argv) > 1:
db = sys.argv[1]
else:
db = get_photos_db()
if db:
print("loading database")
tic = time.perf_counter()
photosdb = osxphotos.PhotosDB(dbfile=db)
toc = time.perf_counter()
print(f"done: took {toc-tic} seconds")
return photosdb
else:
_list_libraries()
sys.exit()
if __name__ == "__main__":
print(f"osxphotos version: {osxphotos._version.__version__}")
photosdb = main()
print(f"database version: {photosdb.db_version}")
print("getting photos")
tic = time.perf_counter()
photos = photosdb.photos(images=True, movies=True)
toc = time.perf_counter()
print(f"found {len(photos)} photos in {toc-tic} seconds")

View File

@@ -1,727 +1,16 @@
import json
import os.path
import platform
import pprint
import sqlite3
import sys
import tempfile
import urllib.parse
from datetime import datetime, timedelta, timezone
from pathlib import Path
from plistlib import load as plistload
from shutil import copyfile
import logging
import CoreFoundation
import objc
import yaml
from Foundation import *
from ._version import __version__
from .photoinfo import PhotoInfo
from .photosdb import PhotosDB
from .utils import _set_debug, _debug, _get_logger
from . import _applescript
# from loguru import logger
# TODO: standardize _ and __ as leading char for private variables
# TODO: fix use of ''' and """
# TODO: find edited photos: see https://github.com/orangeturtle739/photos-export/blob/master/extract_photos.py
# TODO: Add test for imageTimeZoneOffsetSeconds = None
# TODO: Fix command line so multiple --keyword, etc. are AND (instead of OR as they are in .photos())
# Or fix the help text to match behavior
# TODO: Add test for __str__ and to_json
# TODO: fix docstrings
# TODO: fix versions tested to include 10.14.6
# TODO: Add special albums and magic albums
# TODO: cleanup os.path and pathlib code (import pathlib and also from pathlib import Path)
# which Photos library database versions have been tested
# Photos 2.0 (10.12.6) == 2622
# Photos 3.0 (10.13.6) == 3301
# Photos 4.0 (10.14.5) == 4016
# Photos 4.0 (10.4.6) == 4025
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
_TESTED_DB_VERSIONS = ["4025", "4016", "3301", "2622"]
# which major version operating systems have been tested
_TESTED_OS_VERSIONS = ["12", "13", "14"]
_debug = False
def _get_os_version():
# returns tuple containing OS version
# e.g. 10.13.6 = (10, 13, 6)
(ver, major, minor) = platform.mac_ver()[0].split(".")
return (ver, major, minor)
def _check_file_exists(filename):
# returns true if file exists and is not a directory
# otherwise returns false
filename = os.path.abspath(filename)
return os.path.exists(filename) and not os.path.isdir(filename)
class PhotosDB:
def __init__(self, dbfile=None):
# Check OS version
system = platform.system()
(_, major, _) = _get_os_version()
# logger.debug(system, major)
if system != "Darwin" or (major not in _TESTED_OS_VERSIONS):
print(
"WARNING: This module has only been tested with MacOS 10."
+ f"[{', '.join(_TESTED_OS_VERSIONS)}]: "
+ f"you have {system}, OS version: {major}",
file=sys.stderr,
)
# Dict with information about all photos by uuid
self._dbphotos = {}
# Dict with information about all persons/photos by uuid
self._dbfaces_uuid = {}
# Dict with information about all persons/photos by person
self._dbfaces_person = {}
# Dict with information about all keywords/photos by uuid
self._dbkeywords_uuid = {}
# Dict with information about all keywords/photos by keyword
self._dbkeywords_keyword = {}
# Dict with information about all albums/photos by uuid
self._dbalbums_uuid = {}
# Dict with information about all albums/photos by album
self._dbalbums_album = {}
# Dict with information about all the volumes/photos by uuid
self._dbvolumes = {}
# logger.debug(dbfile)
if dbfile is None:
library_path = self.get_photos_library_path()
# logger.debug("library_path: " + library_path)
# TODO: verify library path not None
dbfile = os.path.join(library_path, "database/photos.db")
# logger.debug(dbfile)
# logger.debug(f"filename = {dbfile}")
# TODO: replace os.path with pathlib
# TODO: clean this up -- we'll already know library_path
library_path = os.path.dirname(dbfile)
(library_path, _) = os.path.split(library_path)
masters_path = os.path.join(library_path, "Masters")
self._masters_path = masters_path
# logger.debug(f"library = {library_path}, masters = {masters_path}")
if not _check_file_exists(dbfile):
sys.exit(f"_dbfile {dbfile} does not exist")
# logger.info(f"database filename = {dbfile}")
self._dbfile = dbfile
self._setup_applescript()
self._process_database()
def keywords_as_dict(self):
# return keywords as dict of keyword, count in reverse sorted order (descending)
keywords = {}
for k in self._dbkeywords_keyword.keys():
keywords[k] = len(self._dbkeywords_keyword[k])
keywords = dict(sorted(keywords.items(), key=lambda kv: kv[1], reverse=True))
return keywords
def persons_as_dict(self):
# return persons as dict of person, count in reverse sorted order (descending)
persons = {}
for k in self._dbfaces_person.keys():
persons[k] = len(self._dbfaces_person[k])
persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
return persons
def albums_as_dict(self):
# return albums as dict of albums, count in reverse sorted order (descending)
albums = {}
for k in self._dbalbums_album.keys():
albums[k] = len(self._dbalbums_album[k])
albums = dict(sorted(albums.items(), key=lambda kv: kv[1], reverse=True))
return albums
def keywords(self):
# return list of keywords found in photos database
keywords = self._dbkeywords_keyword.keys()
return list(keywords)
def persons(self):
# return persons as dict of person, count in reverse sorted order (descending)
persons = self._dbfaces_person.keys()
return list(persons)
def albums(self):
# return albums as dict of albums, count in reverse sorted order (descending)
albums = self._dbalbums_album.keys()
return list(albums)
# Various AppleScripts we need
def _setup_applescript(self):
self._scpt_export = ""
self._scpt_launch = ""
self._scpt_quit = ""
# Compile apple script that exports one image
# self._scpt_export = _applescript.AppleScript('''
# on run {arg}
# set thepath to "%s"
# tell application "Photos"
# set theitem to media item id arg
# set thelist to {theitem}
# export thelist to POSIX file thepath
# end tell
# end run
# ''' % (tmppath))
#
# Compile apple script that launches Photos.App
self._scpt_launch = _applescript.AppleScript(
"""
on run
tell application "Photos"
activate
end tell
end run
"""
)
# Compile apple script that quits Photos.App
self._scpt_quit = _applescript.AppleScript(
"""
on run
tell application "Photos"
quit
end tell
end run
"""
)
def get_db_version(self):
# return the database version as stored in LiGlobals table
return self.__db_version
def get_db_path(self):
""" return path to the Photos library database PhotosDB was initialized with """
return os.path.abspath(self._dbfile)
def get_photos_library_path(self):
# return the path to the Photos library
plist_file = Path(
str(Path.home())
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
)
if plist_file.is_file():
with open(plist_file, "rb") as fp:
pl = plistload(fp)
else:
print("could not find plist file: " + str(plist_file), file=sys.stderr)
return None
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
# this is a serialized CFData object
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
if photosurlref != None:
# use CFURLCreateByResolvingBookmarkData to de-serialize bookmark data into a CFURLRef
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
)
# the CFURLRef we got is a sruct that python treats as an array
# I'd like to pass this to CFURLGetFileSystemRepresentation to get the path but
# CFURLGetFileSystemRepresentation barfs when it gets an array from python instead of expected struct
# first element is the path string in form:
# file:///Users/username/Pictures/Photos%20Library.photoslibrary/
photosurlstr = photosurl[0].absoluteString() if photosurl[0] else None
# now coerce the file URI back into an OS path
# surely there must be a better way
if photosurlstr is not None:
photospath = os.path.normpath(
urllib.parse.unquote(urllib.parse.urlparse(photosurlstr).path)
)
else:
print(
"Could not extract photos URL String from IPXDefaultLibraryURLBookmark",
file=sys.stderr,
)
return None
return photospath
else:
print("Could not get path to Photos database", file=sys.stderr)
return None
# TODO: do we need to copy the db-wal write-ahead log file?
def _copy_db_file(self, fname):
# copies the sqlite database file to a temp file
# returns the name of the temp file
# required because python's sqlite3 implementation can't read a locked file
_, tmp = tempfile.mkstemp(suffix=".db", prefix="photos")
# logger.debug("copying " + fname + " to " + tmp)
try:
copyfile(fname, tmp)
except:
print("Error copying " + fname + " to " + tmp, file=sys.stderr)
raise Exception
return tmp
def _open_sql_file(self, file):
fname = file
# logger.debug(f"Trying to open database {fname}")
try:
conn = sqlite3.connect(f"{fname}")
c = conn.cursor()
except sqlite3.Error as e:
print(f"An error occurred: {e.args[0]} {fname}")
sys.exit(3)
# logger.debug("SQLite database is open")
return (conn, c)
def _process_database(self):
global _debug
fname = self._dbfile
# Epoch is Jan 1, 2001
td = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
# Ensure Photos.App is not running
self._scpt_quit.run()
tmp_db = self._copy_db_file(fname)
(conn, c) = self._open_sql_file(tmp_db)
# logger.debug("Have connection with database")
# get database version
c.execute(
"SELECT value from LiGlobals where LiGlobals.keyPath is 'libraryVersion'"
)
for ver in c:
self.__db_version = ver[0]
break # TODO: is there a more pythonic way to do get the first element from cursor?
if self.__db_version not in _TESTED_DB_VERSIONS:
print(
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
+ f" You have database version={self.__db_version} which has not been tested"
)
# Look for all combinations of persons and pictures
# logger.debug("Getting information about persons")
i = 0
c.execute(
"select count(*) from RKFace, RKPerson, RKVersion where RKFace.personID = RKperson.modelID "
+ "and RKFace.imageModelId = RKVersion.modelId and RKVersion.isInTrash = 0"
)
# init_pbar_status("Faces", c.fetchone()[0])
# c.execute("select RKPerson.name, RKFace.imageID from RKFace, RKPerson where RKFace.personID = RKperson.modelID")
c.execute(
"select RKPerson.name, RKVersion.uuid from RKFace, RKPerson, RKVersion, RKMaster "
+ "where RKFace.personID = RKperson.modelID and RKVersion.modelId = RKFace.ImageModelId "
+ "and RKVersion.type = 2 and RKVersion.masterUuid = RKMaster.uuid and "
+ "RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
for person in c:
if person[0] == None:
# logger.debug(f"skipping person = None {person[1]}")
continue
if not person[1] in self._dbfaces_uuid:
self._dbfaces_uuid[person[1]] = []
if not person[0] in self._dbfaces_person:
self._dbfaces_person[person[0]] = []
self._dbfaces_uuid[person[1]].append(person[0])
self._dbfaces_person[person[0]].append(person[1])
# set_pbar_status(i)
i = i + 1
# logger.debug("Finished walking through persons")
# close_pbar_status()
# logger.debug("Getting information about albums")
i = 0
c.execute(
"select count(*) from RKAlbum, RKVersion, RKAlbumVersion where "
+ "RKAlbum.modelID = RKAlbumVersion.albumId and "
+ "RKAlbumVersion.versionID = RKVersion.modelId and "
+ "RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
# init_pbar_status("Albums", c.fetchone()[0])
# c.execute("select RKPerson.name, RKFace.imageID from RKFace, RKPerson where RKFace.personID = RKperson.modelID")
c.execute(
"select RKAlbum.name, RKVersion.uuid from RKAlbum, RKVersion, RKAlbumVersion "
+ "where RKAlbum.modelID = RKAlbumVersion.albumId and "
+ "RKAlbumVersion.versionID = RKVersion.modelId and RKVersion.type = 2 and "
+ "RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
for album in c:
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
if not album[1] in self._dbalbums_uuid:
self._dbalbums_uuid[album[1]] = []
if not album[0] in self._dbalbums_album:
self._dbalbums_album[album[0]] = []
self._dbalbums_uuid[album[1]].append(album[0])
self._dbalbums_album[album[0]].append(album[1])
# logger.debug(f"{album[1]} {album[0]}")
# set_pbar_status(i)
i = i + 1
# logger.debug("Finished walking through albums")
# close_pbar_status()
# logger.debug("Getting information about keywords")
c.execute(
"select count(*) from RKKeyword, RKKeywordForVersion,RKVersion, RKMaster "
+ "where RKKeyword.modelId = RKKeyWordForVersion.keywordID and "
+ "RKVersion.modelID = RKKeywordForVersion.versionID and RKMaster.uuid = "
+ "RKVersion.masterUuid and RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
# init_pbar_status("Keywords", c.fetchone()[0])
c.execute(
"select RKKeyword.name, RKVersion.uuid, RKMaster.uuid from "
+ "RKKeyword, RKKeywordForVersion, RKVersion, RKMaster "
+ "where RKKeyword.modelId = RKKeyWordForVersion.keywordID and "
+ "RKVersion.modelID = RKKeywordForVersion.versionID "
+ "and RKMaster.uuid = RKVersion.masterUuid and RKVersion.type = 2 "
+ "and RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
)
i = 0
for keyword in c:
if not keyword[1] in self._dbkeywords_uuid:
self._dbkeywords_uuid[keyword[1]] = []
if not keyword[0] in self._dbkeywords_keyword:
self._dbkeywords_keyword[keyword[0]] = []
self._dbkeywords_uuid[keyword[1]].append(keyword[0])
self._dbkeywords_keyword[keyword[0]].append(keyword[1])
# logger.debug(f"{keyword[1]} {keyword[0]}")
# set_pbar_status(i)
i = i + 1
# logger.debug("Finished walking through keywords")
# close_pbar_status()
# logger.debug("Getting information about volumes")
c.execute("select count(*) from RKVolume")
# init_pbar_status("Volumes", c.fetchone()[0])
c.execute("select RKVolume.modelId, RKVolume.name from RKVolume")
i = 0
for vol in c:
self._dbvolumes[vol[0]] = vol[1]
# logger.debug(f"{vol[0]} {vol[1]}")
# set_pbar_status(i)
i = i + 1
# logger.debug("Finished walking through volumes")
# close_pbar_status()
# logger.debug("Getting information about photos")
c.execute(
"select count(*) from RKVersion, RKMaster where RKVersion.isInTrash = 0 and "
+ "RKVersion.type = 2 and RKVersion.masterUuid = RKMaster.uuid and "
+ "RKVersion.filename not like '%.pdf'"
)
# init_pbar_status("Photos", c.fetchone()[0])
c.execute(
"select RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename, "
+ "RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating, "
+ "RKVersion.hasAdjustments, RKVersion.hasKeywords, RKVersion.imageTimeZoneOffsetSeconds, "
+ "RKMaster.volumeId, RKMaster.imagePath, RKVersion.extendedDescription, RKVersion.name, "
+ "RKMaster.isMissing "
+ "from RKVersion, RKMaster where RKVersion.isInTrash = 0 and RKVersion.type = 2 and "
+ "RKVersion.masterUuid = RKMaster.uuid and RKVersion.filename not like '%.pdf'"
)
i = 0
for row in c:
# set_pbar_status(i)
i = i + 1
uuid = row[0]
if _debug:
print(f"i = {i:d}, uuid = '{uuid}, master = '{row[2]}")
self._dbphotos[uuid] = {}
self._dbphotos[uuid]["modelID"] = row[1]
self._dbphotos[uuid]["masterUuid"] = row[2]
self._dbphotos[uuid]["filename"] = row[3]
try:
self._dbphotos[uuid]["lastmodifieddate"] = datetime.fromtimestamp(
row[4] + td
)
except:
self._dbphotos[uuid]["lastmodifieddate"] = datetime.fromtimestamp(
row[5] + td
)
self._dbphotos[uuid]["imageDate"] = datetime.fromtimestamp(
row[5] + td
) # - row[9], timezone.utc)
self._dbphotos[uuid]["mainRating"] = row[6]
self._dbphotos[uuid]["hasAdjustments"] = row[7]
self._dbphotos[uuid]["hasKeywords"] = row[8]
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
self._dbphotos[uuid]["volumeId"] = row[10]
self._dbphotos[uuid]["imagePath"] = row[11]
self._dbphotos[uuid]["extendedDescription"] = row[12]
self._dbphotos[uuid]["name"] = row[13]
self._dbphotos[uuid]["isMissing"] = row[14]
# logger.debug(
# "Fetching data for photo %d %s %s %s %s %s: %s"
# % (
# i,
# uuid,
# self._dbphotos[uuid]["masterUuid"],
# self._dbphotos[uuid]["volumeId"],
# self._dbphotos[uuid]["filename"],
# self._dbphotos[uuid]["extendedDescription"],
# self._dbphotos[uuid]["imageDate"],
# )
# )
# close_pbar_status()
conn.close()
# add faces and keywords to photo data
for uuid in self._dbphotos:
# keywords
if self._dbphotos[uuid]["hasKeywords"] == 1:
self._dbphotos[uuid]["keywords"] = self._dbkeywords_uuid[uuid]
else:
self._dbphotos[uuid]["keywords"] = []
if uuid in self._dbfaces_uuid:
self._dbphotos[uuid]["hasPersons"] = 1
self._dbphotos[uuid]["persons"] = self._dbfaces_uuid[uuid]
else:
self._dbphotos[uuid]["hasPersons"] = 0
self._dbphotos[uuid]["persons"] = []
if uuid in self._dbalbums_uuid:
self._dbphotos[uuid]["albums"] = self._dbalbums_uuid[uuid]
self._dbphotos[uuid]["hasAlbums"] = 1
else:
self._dbphotos[uuid]["albums"] = []
self._dbphotos[uuid]["hasAlbums"] = 0
if self._dbphotos[uuid]["volumeId"] is not None:
self._dbphotos[uuid]["volume"] = self._dbvolumes[
self._dbphotos[uuid]["volumeId"]
]
else:
self._dbphotos[uuid]["volume"] = None
# remove temporary copy of the database
try:
# logger.info("Removing temporary database file: " + tmp_db)
os.remove(tmp_db)
except:
print("Could not remove temporary database: " + tmp_db, file=sys.stderr)
if _debug:
pp = pprint.PrettyPrinter(indent=4)
print("Faces:")
pp.pprint(self._dbfaces_uuid)
print("Keywords by uuid:")
pp.pprint(self._dbkeywords_uuid)
print("Keywords by keyword:")
pp.pprint(self._dbkeywords_keyword)
print("Albums by uuid:")
pp.pprint(self._dbalbums_uuid)
print("Albums by album:")
pp.pprint(self._dbalbums_album)
print("Volumes:")
pp.pprint(self._dbvolumes)
print("Photos:")
pp.pprint(self._dbphotos)
# logger.debug(f"processed {len(self._dbphotos)} photos")
"""
Return a list of PhotoInfo objects
If called with no args, returns the entire database of photos
If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
"""
def photos(self, keywords=[], uuid=[], persons=[], albums=[]):
# TODO: remove the logger code then dangling else: pass statements
photos_sets = [] # list of photo sets to perform intersection of
if not keywords and not uuid and not persons and not albums:
# return all the photos
# append keys of all photos as a single set to photos_sets
# logger.debug("return all photos")
photos_sets.append(set(self._dbphotos.keys()))
else:
if albums:
for album in albums:
# logger.info(f"album={album}")
if album in self._dbalbums_album:
# logger.info(f"processing album {album}:")
photos_sets.append(set(self._dbalbums_album[album]))
else:
# logger.debug(f"Could not find album '{album}' in database")
pass
if uuid:
for u in uuid:
# logger.info(f"uuid={u}")
if u in self._dbphotos:
# logger.info(f"processing uuid {u}:")
photos_sets.append(set([u]))
else:
# logger.debug(f"Could not find uuid '{u}' in database")
pass
if keywords:
for keyword in keywords:
# logger.info(f"keyword={keyword}")
if keyword in self._dbkeywords_keyword:
# logger.info(f"processing keyword {keyword}:")
photos_sets.append(set(self._dbkeywords_keyword[keyword]))
# logger.debug(f"photos_sets {photos_sets}")
else:
# logger.debug(f"Could not find keyword '{keyword}' in database")
pass
if persons:
for person in persons:
# logger.info(f"person={person}")
if person in self._dbfaces_person:
# logger.info(f"processing person {person}:")
photos_sets.append(set(self._dbfaces_person[person]))
else:
# logger.debug(f"Could not find person '{person}' in database")
pass
photoinfo = []
if photos_sets: # found some photos
# get the intersection of each argument/search criteria
for p in set.intersection(*photos_sets):
# logger.debug(f"p={p}")
info = PhotoInfo(db=self, uuid=p, info=self._dbphotos[p])
# logger.debug(f"info={info}")
photoinfo.append(info)
return photoinfo
def __repr__(self):
return f"osxphotos.PhotosDB(dbfile='{self.get_db_path()}')"
"""
Info about a specific photo, contains all the details we know about the photo
including keywords, persons, albums, uuid, path, etc.
"""
class PhotoInfo:
def __init__(self, db=None, uuid=None, info=None):
self.__uuid = uuid
self.__info = info
self.__db = db
def filename(self):
return self.__info["filename"]
def date(self):
""" image creation date as timezone aware datetime object """
imagedate = self.__info["imageDate"]
delta = timedelta(seconds=self.__info["imageTimeZoneOffsetSeconds"])
tz = timezone(delta)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
def tzoffset(self):
""" timezone offset from UTC in seconds """
return self.__info["imageTimeZoneOffsetSeconds"]
def path(self):
photopath = ""
vol = self.__info["volume"]
if vol is not None:
photopath = os.path.join("/Volumes", vol, self.__info["imagePath"])
else:
photopath = os.path.join(self.__db._masters_path, self.__info["imagePath"])
if self.__info["isMissing"] == 1:
# logger.warning(
# f"Skipping photo, not yet downloaded from iCloud: {photopath}"
# )
# logger.debug(self.__info)
photopath = None # path would be meaningless until downloaded
# TODO: Is there a way to use applescript to force the download in this
return photopath
def description(self):
return self.__info["extendedDescription"]
def persons(self):
return self.__info["persons"]
def albums(self):
return self.__info["albums"]
def keywords(self):
return self.__info["keywords"]
def name(self):
return self.__info["name"]
def uuid(self):
return self.__uuid
def ismissing(self):
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
do not immediately get written to disk. In particular, I've noticed that downloading
an image from the cloud does not force the database to be updated until something else
e.g. an edit, keyword, etc. occurs forcing a database synch
The exact process / timing is a mystery to be but be aware that if some photos were recently
downloaded from cloud to local storate their status in the database might still show
isMissing = 1
"""
return True if self.__info["isMissing"] == 1 else False
def hasadjustments(self):
return True if self.__info["hasAdjustments"] == 1 else False
def __repr__(self):
return f"osxphotos.PhotoInfo(db={self.__db}, uuid='{self.__uuid}', info={self.__info})"
def __str__(self):
info = {
"uuid": self.uuid(),
"filename": self.filename(),
"date": str(self.date()),
"description": self.description(),
"name": self.name(),
"keywords": self.keywords(),
"albums": self.albums(),
"persons": self.persons(),
"path": self.path(),
"ismissing": self.ismissing(),
"hasadjustments": self.hasadjustments(),
}
return yaml.dump(info, sort_keys=False)
def to_json(self):
""" return JSON representation """
pic = {
"uuid": self.uuid(),
"filename": self.filename(),
"date": str(self.date()),
"description": self.description(),
"name": self.name(),
"keywords": self.keywords(),
"albums": self.albums(),
"persons": self.persons(),
"path": self.path(),
"ismissing": self.ismissing(),
"hasadjustments": self.hasadjustments(),
}
return json.dumps(pic)
# compare two PhotoInfo objects for equality
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.__dict__ == other.__dict__
else:
return False
def __ne__(self, other):
return not self.__eq__(other)

1482
osxphotos/__main__.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,207 +0,0 @@
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
"""
This code is from py-applescript, a public domain package available at:
https://github.com/rdhyee/py-applescript
I've included the whole thing here for simplicity as there is more than one
applescript packge on PyPi so there's ambiguity as to which one "import applescript"
would use if user had installed another library.
This package is used instead of the others because it uses a native PyObjC
bridge and is thus much faster than others which use osascript.
"""
import sys
from Foundation import (
NSAppleScript,
NSAppleEventDescriptor,
NSURL,
NSAppleScriptErrorMessage,
NSAppleScriptErrorBriefMessage,
NSAppleScriptErrorNumber,
NSAppleScriptErrorAppName,
NSAppleScriptErrorRange,
)
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
from . import kae
__all__ = ["AppleScript", "ScriptError", "AEType", "AEEnum", "kMissingValue", "kae"]
######################################################################
class AppleScript:
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
"""
_codecs = Codecs()
def __init__(self, source=None, path=None):
"""
source : str | None -- AppleScript source code
path : str | None -- full path to .scpt/.applescript file
Notes:
- Either the path or the source argument must be provided.
- If the script cannot be read/compiled, a ScriptError is raised.
"""
if path:
url = NSURL.fileURLWithPath_(path)
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(
url, None
)
if errorinfo:
raise ScriptError(errorinfo)
elif source:
self._script = NSAppleScript.alloc().initWithSource_(source)
else:
raise ValueError("Missing source or path argument.")
if not self._script.isCompiled():
errorinfo = self._script.compileAndReturnError_(None)[1]
if errorinfo:
raise ScriptError(errorinfo)
def __repr__(self):
s = self.source
return "AppleScript({})".format(
repr(s) if len(s) < 100 else "{}...{}".format(repr(s)[:80], repr(s)[-17:])
)
##
def _newevent(self, suite, code, args):
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
fourcharcode(suite),
fourcharcode(code),
NSAppleEventDescriptor.nullDescriptor(),
0,
0,
)
evt.setDescriptor_forKeyword_(
self._codecs.pack(args), fourcharcode(kae.keyDirectObject)
)
return evt
def _unpackresult(self, result, errorinfo):
if not result:
raise ScriptError(errorinfo)
return self._codecs.unpack(result)
##
source = property(
lambda self: str(self._script.source()), doc="str -- the script's source code"
)
def run(self, *args):
""" Run the script, optionally passing arguments to its run handler.
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
Result : anything | None -- the script's return value, if any
Notes:
- The run handler must be explicitly declared in order to pass arguments.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
if args:
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
else:
return self._unpackresult(*self._script.executeAndReturnError_(None))
def call(self, name, *args):
""" Call the specified user-defined handler.
name : str -- the handler's name (case-sensitive)
args : anything -- arguments to pass to script, if any; see documentation for supported types
Result : anything | None -- the script's return value, if any
Notes:
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
evt = self._newevent(
kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args
)
evt.setDescriptor_forKeyword_(
NSAppleEventDescriptor.descriptorWithString_(name),
fourcharcode(kae.keyASSubroutineName),
)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
##
class ScriptError(Exception):
""" Indicates an AppleScript compilation/execution error. """
def __init__(self, errorinfo):
self._errorinfo = dict(errorinfo)
def __repr__(self):
return "ScriptError({})".format(self._errorinfo)
@property
def message(self):
""" str -- the error message """
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
if not msg:
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, "Script Error")
return msg
number = property(
lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
doc="int | None -- the error number, if given",
)
appname = property(
lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
doc="str | None -- the name of the application that reported the error, where relevant",
)
@property
def range(self):
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
range = self._errorinfo.get(NSAppleScriptErrorRange)
if range:
start = range.rangeValue().location
end = start + range.rangeValue().length
return (start, end)
else:
return None
def __str__(self):
msg = self.message
for s, v in [
(" ({})", self.number),
(" app={!r}", self.appname),
(" range={0[0]}-{0[1]}", self.range),
]:
if v is not None:
msg += s.format(v)
return (
msg.encode("ascii", "replace") if sys.version_info.major < 3 else msg
) # 2.7 compatibility
##
kMissingValue = AEType(kae.cMissingValue) # convenience constant

View File

@@ -0,0 +1,162 @@
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
import sys
from Foundation import NSAppleScript, NSAppleEventDescriptor, NSURL, \
NSAppleScriptErrorMessage, NSAppleScriptErrorBriefMessage, \
NSAppleScriptErrorNumber, NSAppleScriptErrorAppName, NSAppleScriptErrorRange
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
from . import kae
__all__ = ['AppleScript', 'ScriptError', 'AEType', 'AEEnum', 'kMissingValue', 'kae']
######################################################################
class AppleScript:
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
"""
_codecs = Codecs()
def __init__(self, source=None, path=None):
"""
source : str | None -- AppleScript source code
path : str | None -- full path to .scpt/.applescript file
Notes:
- Either the path or the source argument must be provided.
- If the script cannot be read/compiled, a ScriptError is raised.
"""
if path:
url = NSURL.fileURLWithPath_(path)
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(url, None)
if errorinfo:
raise ScriptError(errorinfo)
elif source:
self._script = NSAppleScript.alloc().initWithSource_(source)
else:
raise ValueError("Missing source or path argument.")
if not self._script.isCompiled():
errorinfo = self._script.compileAndReturnError_(None)[1]
if errorinfo:
raise ScriptError(errorinfo)
def __repr__(self):
s = self.source
return 'AppleScript({})'.format(repr(s) if len(s) < 100 else '{}...{}'.format(repr(s)[:80], repr(s)[-17:]))
##
def _newevent(self, suite, code, args):
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
fourcharcode(suite), fourcharcode(code), NSAppleEventDescriptor.nullDescriptor(), 0, 0)
evt.setDescriptor_forKeyword_(self._codecs.pack(args), fourcharcode(kae.keyDirectObject))
return evt
def _unpackresult(self, result, errorinfo):
if not result:
raise ScriptError(errorinfo)
return self._codecs.unpack(result)
##
source = property(lambda self: str(self._script.source()), doc="str -- the script's source code")
def run(self, *args):
""" Run the script, optionally passing arguments to its run handler.
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
Result : anything | None -- the script's return value, if any
Notes:
- The run handler must be explicitly declared in order to pass arguments.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
if args:
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
else:
return self._unpackresult(*self._script.executeAndReturnError_(None))
def call(self, name, *args):
""" Call the specified user-defined handler.
name : str -- the handler's name (case-sensitive)
args : anything -- arguments to pass to script, if any; see documentation for supported types
Result : anything | None -- the script's return value, if any
Notes:
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
- If execution fails, a ScriptError is raised.
"""
evt = self._newevent(kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args)
evt.setDescriptor_forKeyword_(NSAppleEventDescriptor.descriptorWithString_(name),
fourcharcode(kae.keyASSubroutineName))
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
##
class ScriptError(Exception):
""" Indicates an AppleScript compilation/execution error. """
def __init__(self, errorinfo):
self._errorinfo = dict(errorinfo)
def __repr__(self):
return 'ScriptError({})'.format(self._errorinfo)
@property
def message(self):
""" str -- the error message """
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
if not msg:
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, 'Script Error')
return msg
number = property(lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
doc="int | None -- the error number, if given")
appname = property(lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
doc="str | None -- the name of the application that reported the error, where relevant")
@property
def range(self):
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
range = self._errorinfo.get(NSAppleScriptErrorRange)
if range:
start = range.rangeValue().location
end = start + range.rangeValue().length
return (start, end)
else:
return None
def __str__(self):
msg = self.message
for s, v in [(' ({})', self.number), (' app={!r}', self.appname), (' range={0[0]}-{0[1]}', self.range)]:
if v is not None:
msg += s.format(v)
return msg.encode('ascii', 'replace') if sys.version_info.major < 3 else msg # 2.7 compatibility
##
kMissingValue = AEType(kae.cMissingValue) # convenience constant

View File

@@ -0,0 +1,269 @@
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
import datetime, struct, sys
from Foundation import NSAppleEventDescriptor, NSURL
from . import kae
__all__ = ['Codecs', 'AEType', 'AEEnum']
######################################################################
def fourcharcode(code):
""" Convert four-char code for use in NSAppleEventDescriptor methods.
code : bytes -- four-char code, e.g. b'utxt'
Result : int -- OSType, e.g. 1970567284
"""
return struct.unpack('>I', code)[0]
#######
class Codecs:
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
kMacEpoch = datetime.datetime(1904, 1, 1)
kUSRF = fourcharcode(kae.keyASUserRecordFields)
def __init__(self):
# Clients may add/remove/replace encoder and decoder items:
self.encoders = {
NSAppleEventDescriptor.class__(): self.packdesc,
type(None): self.packnone,
bool: self.packbool,
int: self.packint,
float: self.packfloat,
bytes: self.packbytes,
str: self.packstr,
list: self.packlist,
tuple: self.packlist,
dict: self.packdict,
datetime.datetime: self.packdatetime,
AEType: self.packtype,
AEEnum: self.packenum,
}
if sys.version_info.major < 3: # 2.7 compatibility
self.encoders[unicode] = self.packstr
self.decoders = {fourcharcode(k): v for k, v in {
kae.typeNull: self.unpacknull,
kae.typeBoolean: self.unpackboolean,
kae.typeFalse: self.unpackboolean,
kae.typeTrue: self.unpackboolean,
kae.typeSInt32: self.unpacksint32,
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
kae.typeUTF8Text: self.unpackunicodetext,
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
kae.typeUnicodeText: self.unpackunicodetext,
kae.typeLongDateTime: self.unpacklongdatetime,
kae.typeAEList: self.unpackaelist,
kae.typeAERecord: self.unpackaerecord,
kae.typeAlias: self.unpackfile,
kae.typeFSS: self.unpackfile,
kae.typeFSRef: self.unpackfile,
kae.typeFileURL: self.unpackfile,
kae.typeType: self.unpacktype,
kae.typeEnumeration: self.unpackenumeration,
}.items()}
def pack(self, data):
"""Pack Python data.
data : anything -- a Python value
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
"""
try:
return self.encoders[data.__class__](data) # quick lookup by type/class
except (KeyError, AttributeError) as e:
for type, encoder in self.encoders.items(): # slower but more thorough lookup that can handle subtypes/subclasses
if isinstance(data, type):
return encoder(data)
raise TypeError("Can't pack data into an AEDesc (unsupported type): {!r}".format(data))
def unpack(self, desc):
"""Unpack an Apple event descriptor.
desc : NSAppleEventDescriptor
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
"""
decoder = self.decoders.get(desc.descriptorType())
# unpack known type
if decoder:
return decoder(desc)
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
if rec:
rec = self.unpackaerecord(rec)
rec[AEType(kae.pClass)] = AEType(struct.pack('>I', desc.descriptorType()))
return rec
# return as-is
return desc
##
def _packbytes(self, desctype, data):
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
fourcharcode(desctype), data, len(data))
def packdesc(self, val):
return val
def packnone(self, val):
return NSAppleEventDescriptor.nullDescriptor()
def packbool(self, val):
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
def packint(self, val):
if (-2**31) <= val < (2**31):
return NSAppleEventDescriptor.descriptorWithInt32_(val)
else:
return self.pack(float(val))
def packfloat(self, val):
return self._packbytes(kae.typeFloat, struct.pack('d', val))
def packbytes(self, val):
return self._packbytes(kae.typeData, val)
def packstr(self, val):
return NSAppleEventDescriptor.descriptorWithString_(val)
def packdatetime(self, val):
delta = val - self.kMacEpoch
sec = delta.days * 3600 * 24 + delta.seconds
return self._packbytes(kae.typeLongDateTime, struct.pack('q', sec))
def packlist(self, val):
lst = NSAppleEventDescriptor.listDescriptor()
for item in val:
lst.insertDescriptor_atIndex_(self.pack(item), 0)
return lst
def packdict(self, val):
record = NSAppleEventDescriptor.recordDescriptor()
usrf = desctype = None
for key, value in val.items():
if isinstance(key, AEType):
if key.code == kae.pClass and isinstance(value, AEType): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
desctype = value
else:
record.setDescriptor_forKeyword_(self.pack(value), fourcharcode(key.code))
else:
if not usrf:
usrf = NSAppleEventDescriptor.listDescriptor()
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
if usrf:
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
if desctype:
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
if newrecord:
record = newrecord
else: # coercion failed for some reason, so pack as normal key-value pair
record.setDescriptor_forKeyword_(self.pack(desctype), fourcharcode(key.code))
return record
def packtype(self, val):
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
def packenum(self, val):
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
#######
def unpacknull(self, desc):
return None
def unpackboolean(self, desc):
return desc.booleanValue()
def unpacksint32(self, desc):
return desc.int32Value()
def unpackfloat64(self, desc):
return struct.unpack('d', bytes(desc.data()))[0]
def unpackunicodetext(self, desc):
return desc.stringValue()
def unpacklongdatetime(self, desc):
return self.kMacEpoch + datetime.timedelta(seconds=struct.unpack('q', bytes(desc.data()))[0])
def unpackaelist(self, desc):
return [self.unpack(desc.descriptorAtIndex_(i + 1)) for i in range(desc.numberOfItems())]
def unpackaerecord(self, desc):
dct = {}
for i in range(desc.numberOfItems()):
key = desc.keywordForDescriptorAtIndex_(i + 1)
value = desc.descriptorForKeyword_(key)
if key == self.kUSRF:
lst = self.unpackaelist(value)
for i in range(0, len(lst), 2):
dct[lst[i]] = lst[i+1]
else:
dct[AEType(struct.pack('>I', key))] = self.unpack(value)
return dct
def unpacktype(self, desc):
return AEType(struct.pack('>I', desc.typeCodeValue()))
def unpackenumeration(self, desc):
return AEEnum(struct.pack('>I', desc.enumCodeValue()))
def unpackfile(self, desc):
url = bytes(desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()).decode('utf8')
return NSURL.URLWithString_(url).path()
#######
class AETypeBase:
""" Base class for AEType and AEEnum.
Notes:
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
"""
def __init__(self, code):
"""
code : bytes -- four-char code, e.g. b'utxt'
"""
if not isinstance(code, bytes):
raise TypeError('invalid code (not a bytes object): {!r}'.format(code))
elif len(code) != 4:
raise ValueError('invalid code (not four bytes long): {!r}'.format(code))
self._code = code
code = property(lambda self:self._code, doc="bytes -- four-char code, e.g. b'utxt'")
def __hash__(self):
return hash(self._code)
def __eq__(self, val):
return val.__class__ == self.__class__ and val.code == self._code
def __ne__(self, val):
return not self == val
def __repr__(self):
return "{}({!r})".format(self.__class__.__name__, self._code)
##
class AEType(AETypeBase):
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
class AEEnum(AETypeBase):
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""

File diff suppressed because it is too large Load Diff

39
osxphotos/_constants.py Normal file
View File

@@ -0,0 +1,39 @@
"""
Constants used by osxphotos
"""
import os.path
# which Photos library database versions have been tested
# Photos 2.0 (10.12.6) == 2622
# Photos 3.0 (10.13.6) == 3301
# Photos 4.0 (10.14.5) == 4016
# Photos 4.0 (10.14.6) == 4025
# Photos 5.0 (10.15.0) == 6000
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
# only version 3 - 4 have RKVersion.selfPortrait
_PHOTOS_3_VERSION = "3301"
# versions later than this have a different database structure
_PHOTOS_5_VERSION = "6000"
# which major version operating systems have been tested
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
# Photos 5 has persons who are empty string if unidentified face
_UNKNOWN_PERSON = "_UNKNOWN_"
_EXIF_TOOL_URL = "https://exiftool.org/"
# Where are shared iCloud photos located?
_PHOTOS_5_SHARED_PHOTO_PATH = "resources/cloudsharing/data"
# What type of file? Based on ZGENERICASSET.ZKIND in Photos 5 database
_PHOTO_TYPE = 0
_MOVIE_TYPE = 1
# Name of XMP template file
_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
_XMP_TEMPLATE_NAME = "xmp_sidecar.mako"

3
osxphotos/_version.py Normal file
View File

@@ -0,0 +1,3 @@
""" version info """
__version__ = "0.24.2"

View File

@@ -1,294 +0,0 @@
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
import datetime, struct, sys
from Foundation import NSAppleEventDescriptor, NSURL
from . import kae
__all__ = ["Codecs", "AEType", "AEEnum"]
######################################################################
def fourcharcode(code):
""" Convert four-char code for use in NSAppleEventDescriptor methods.
code : bytes -- four-char code, e.g. b'utxt'
Result : int -- OSType, e.g. 1970567284
"""
return struct.unpack(">I", code)[0]
#######
class Codecs:
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
kMacEpoch = datetime.datetime(1904, 1, 1)
kUSRF = fourcharcode(kae.keyASUserRecordFields)
def __init__(self):
# Clients may add/remove/replace encoder and decoder items:
self.encoders = {
NSAppleEventDescriptor.class__(): self.packdesc,
type(None): self.packnone,
bool: self.packbool,
int: self.packint,
float: self.packfloat,
bytes: self.packbytes,
str: self.packstr,
list: self.packlist,
tuple: self.packlist,
dict: self.packdict,
datetime.datetime: self.packdatetime,
AEType: self.packtype,
AEEnum: self.packenum,
}
if sys.version_info.major < 3: # 2.7 compatibility
self.encoders[unicode] = self.packstr
self.decoders = {
fourcharcode(k): v
for k, v in {
kae.typeNull: self.unpacknull,
kae.typeBoolean: self.unpackboolean,
kae.typeFalse: self.unpackboolean,
kae.typeTrue: self.unpackboolean,
kae.typeSInt32: self.unpacksint32,
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
kae.typeUTF8Text: self.unpackunicodetext,
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
kae.typeUnicodeText: self.unpackunicodetext,
kae.typeLongDateTime: self.unpacklongdatetime,
kae.typeAEList: self.unpackaelist,
kae.typeAERecord: self.unpackaerecord,
kae.typeAlias: self.unpackfile,
kae.typeFSS: self.unpackfile,
kae.typeFSRef: self.unpackfile,
kae.typeFileURL: self.unpackfile,
kae.typeType: self.unpacktype,
kae.typeEnumeration: self.unpackenumeration,
}.items()
}
def pack(self, data):
"""Pack Python data.
data : anything -- a Python value
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
"""
try:
return self.encoders[data.__class__](data) # quick lookup by type/class
except (KeyError, AttributeError) as e:
for (
type,
encoder,
) in (
self.encoders.items()
): # slower but more thorough lookup that can handle subtypes/subclasses
if isinstance(data, type):
return encoder(data)
raise TypeError(
"Can't pack data into an AEDesc (unsupported type): {!r}".format(data)
)
def unpack(self, desc):
"""Unpack an Apple event descriptor.
desc : NSAppleEventDescriptor
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
"""
decoder = self.decoders.get(desc.descriptorType())
# unpack known type
if decoder:
return decoder(desc)
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
if rec:
rec = self.unpackaerecord(rec)
rec[AEType(kae.pClass)] = AEType(struct.pack(">I", desc.descriptorType()))
return rec
# return as-is
return desc
##
def _packbytes(self, desctype, data):
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
fourcharcode(desctype), data, len(data)
)
def packdesc(self, val):
return val
def packnone(self, val):
return NSAppleEventDescriptor.nullDescriptor()
def packbool(self, val):
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
def packint(self, val):
if (-2 ** 31) <= val < (2 ** 31):
return NSAppleEventDescriptor.descriptorWithInt32_(val)
else:
return self.pack(float(val))
def packfloat(self, val):
return self._packbytes(kae.typeFloat, struct.pack("d", val))
def packbytes(self, val):
return self._packbytes(kae.typeData, val)
def packstr(self, val):
return NSAppleEventDescriptor.descriptorWithString_(val)
def packdatetime(self, val):
delta = val - self.kMacEpoch
sec = delta.days * 3600 * 24 + delta.seconds
return self._packbytes(kae.typeLongDateTime, struct.pack("q", sec))
def packlist(self, val):
lst = NSAppleEventDescriptor.listDescriptor()
for item in val:
lst.insertDescriptor_atIndex_(self.pack(item), 0)
return lst
def packdict(self, val):
record = NSAppleEventDescriptor.recordDescriptor()
usrf = desctype = None
for key, value in val.items():
if isinstance(key, AEType):
if key.code == kae.pClass and isinstance(
value, AEType
): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
desctype = value
else:
record.setDescriptor_forKeyword_(
self.pack(value), fourcharcode(key.code)
)
else:
if not usrf:
usrf = NSAppleEventDescriptor.listDescriptor()
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
if usrf:
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
if desctype:
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
if newrecord:
record = newrecord
else: # coercion failed for some reason, so pack as normal key-value pair
record.setDescriptor_forKeyword_(
self.pack(desctype), fourcharcode(key.code)
)
return record
def packtype(self, val):
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
def packenum(self, val):
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
#######
def unpacknull(self, desc):
return None
def unpackboolean(self, desc):
return desc.booleanValue()
def unpacksint32(self, desc):
return desc.int32Value()
def unpackfloat64(self, desc):
return struct.unpack("d", bytes(desc.data()))[0]
def unpackunicodetext(self, desc):
return desc.stringValue()
def unpacklongdatetime(self, desc):
return self.kMacEpoch + datetime.timedelta(
seconds=struct.unpack("q", bytes(desc.data()))[0]
)
def unpackaelist(self, desc):
return [
self.unpack(desc.descriptorAtIndex_(i + 1))
for i in range(desc.numberOfItems())
]
def unpackaerecord(self, desc):
dct = {}
for i in range(desc.numberOfItems()):
key = desc.keywordForDescriptorAtIndex_(i + 1)
value = desc.descriptorForKeyword_(key)
if key == self.kUSRF:
lst = self.unpackaelist(value)
for i in range(0, len(lst), 2):
dct[lst[i]] = lst[i + 1]
else:
dct[AEType(struct.pack(">I", key))] = self.unpack(value)
return dct
def unpacktype(self, desc):
return AEType(struct.pack(">I", desc.typeCodeValue()))
def unpackenumeration(self, desc):
return AEEnum(struct.pack(">I", desc.enumCodeValue()))
def unpackfile(self, desc):
url = bytes(
desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()
).decode("utf8")
return NSURL.URLWithString_(url).path()
#######
class AETypeBase:
""" Base class for AEType and AEEnum.
Notes:
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
"""
def __init__(self, code):
"""
code : bytes -- four-char code, e.g. b'utxt'
"""
if not isinstance(code, bytes):
raise TypeError("invalid code (not a bytes object): {!r}".format(code))
elif len(code) != 4:
raise ValueError("invalid code (not four bytes long): {!r}".format(code))
self._code = code
code = property(
lambda self: self._code, doc="bytes -- four-char code, e.g. b'utxt'"
)
def __hash__(self):
return hash(self._code)
def __eq__(self, val):
return val.__class__ == self.__class__ and val.code == self._code
def __ne__(self, val):
return not self == val
def __repr__(self):
return "{}({!r})".format(self.__class__.__name__, self._code)
##
class AEType(AETypeBase):
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
class AEEnum(AETypeBase):
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""

View File

@@ -1,200 +0,0 @@
import csv
import json
import sys
import click
import yaml
import osxphotos
class CLI_Obj:
def __init__(self, db=None, json=False):
self.photosdb = osxphotos.PhotosDB(dbfile=db)
self.json = json
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CTX_SETTINGS)
@click.option(
"--db",
required=False,
metavar="<Photos database path>",
default=None,
help="Specify database file",
)
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format",
)
@click.pass_context
def cli(ctx, db, json):
ctx.obj = CLI_Obj(db=db, json=json)
@cli.command()
@click.pass_obj
def keywords(cli_obj):
""" print out keywords found in the Photos library"""
keywords = {"keywords": cli_obj.photosdb.keywords_as_dict()}
if cli_obj.json:
print(json.dumps(keywords))
else:
print(yaml.dump(keywords, sort_keys=False))
@cli.command()
@click.pass_obj
def albums(cli_obj):
""" print out albums found in the Photos library """
albums = {"albums": cli_obj.photosdb.albums_as_dict()}
if cli_obj.json:
print(json.dumps(albums))
else:
print(yaml.dump(albums, sort_keys=False))
@cli.command()
@click.pass_obj
def persons(cli_obj):
""" print out persons (faces) found in the Photos library """
persons = {"persons": cli_obj.photosdb.persons_as_dict()}
if cli_obj.json:
print(json.dumps(persons))
else:
print(yaml.dump(persons, sort_keys=False))
@cli.command()
@click.pass_obj
def info(cli_obj):
""" print out descriptive info of the Photos library database """
pdb = cli_obj.photosdb
info = {}
info["database_path"] = pdb.get_db_path()
info["database_version"] = pdb.get_db_version()
photos = pdb.photos()
info["photo_count"] = len(photos)
keywords = pdb.keywords_as_dict()
info["keywords_count"] = len(keywords)
info["keywords"] = keywords
albums = pdb.albums_as_dict()
info["albums_count"] = len(albums)
info["albums"] = albums
persons = pdb.persons_as_dict()
info["persons_count"] = len(persons)
info["persons"] = persons
if cli_obj.json:
print(json.dumps(info))
else:
print(yaml.dump(info, sort_keys=False))
@cli.command()
@click.pass_obj
def dump(cli_obj):
""" print list of all photos & associated info from the Photos library """
pdb = cli_obj.photosdb
photos = pdb.photos()
print_photo_info(photos, cli_obj.json)
@cli.command()
@click.option("--keyword", default=None, multiple=True, help="search for keyword(s)")
@click.option("--person", default=None, multiple=True, help="search for person(s)")
@click.option("--album", default=None, multiple=True, help="search for album(s)")
@click.option("--uuid", default=None, multiple=True, help="search for UUID(s)")
@click.option(
"--json",
required=False,
is_flag=True,
default=False,
help="Print output in JSON format",
)
@click.pass_obj
@click.pass_context
def query(ctx, cli_obj, keyword, person, album, uuid, json):
""" query the Photos database using 1 or more search options """
# if no query terms, show help and return
if not keyword and not person and not album and not uuid:
print(cli.commands["query"].get_help(ctx))
return
else:
photos = cli_obj.photosdb.photos(
keywords=keyword, persons=person, albums=album, uuid=uuid
)
print_photo_info(photos, cli_obj.json or json)
@cli.command()
@click.argument("topic", default=None, required=False, nargs=1)
@click.pass_context
def help(ctx, topic, **kw):
""" print help; for help on commands: help <command> """
if topic is None:
print(ctx.parent.get_help())
else:
print(cli.commands[topic].get_help(ctx))
def print_photo_info(photos, json=False):
if json:
dump = []
for p in photos:
dump.append(p.to_json())
print(f"[{', '.join(dump)}]")
else:
# dump as CSV
csv_writer = csv.writer(
sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
)
dump = []
# add headers
dump.append(
[
"uuid",
"filename",
"date",
"description",
"name",
"keywords",
"albums",
"persons",
"path",
"ismissing",
"hasadjustments",
]
)
for p in photos:
dump.append(
[
p.uuid(),
p.filename(),
str(p.date()),
p.description(),
p.name(),
", ".join(p.keywords()),
", ".join(p.albums()),
", ".join(p.persons()),
p.path(),
p.ismissing(),
p.hasadjustments(),
]
)
for row in dump:
csv_writer.writerow(row)
if __name__ == "__main__":
cli()

251
osxphotos/exiftool.py Normal file
View File

@@ -0,0 +1,251 @@
""" Yet another simple exiftool wrapper
I rolled my own for following reasons:
1. I wanted something under MIT license (best alternative was licensed under GPL/BSD)
2. I wanted singleton behavior so only a single exiftool process was ever running
If these aren't important to you, I highly recommend you use Sven Marnach's excellent
pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
import json
import logging
import os
import subprocess
import sys
from functools import lru_cache # pylint: disable=syntax-error
from .utils import _debug
# exiftool -stay_open commands outputs this EOF marker after command is run
EXIFTOOL_STAYOPEN_EOF = "{ready}"
EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
@lru_cache(maxsize=1)
def get_exiftool_path():
""" return path of exiftool, cache result """
result = subprocess.run(["which", "exiftool"], stdout=subprocess.PIPE)
exiftool_path = result.stdout.decode("utf-8")
if _debug():
logging.debug("exiftool path = %s" % (exiftool_path))
if exiftool_path:
return exiftool_path.rstrip()
else:
raise FileNotFoundError(
"Could not find exiftool. Please download and install from "
"https://exiftool.org/"
)
class _ExifToolProc:
""" Runs exiftool in a subprocess via Popen
Creates a singleton object """
def __new__(cls, *args, **kwargs):
""" create new object or return instance of already created singleton """
if not hasattr(cls, "instance") or not cls.instance:
cls.instance = super().__new__(cls)
return cls.instance
def __init__(self, exiftool=None):
""" construct _ExifToolProc singleton object or return instance of already created object
exiftool: optional path to exiftool binary (if not provided, will search path to find it) """
if hasattr(self, "_process_running") and self._process_running:
# already running
if exiftool is not None:
logging.warning(
f"exiftool subprocess already running, "
f"ignoring exiftool={exiftool}"
)
return
if exiftool:
self._exiftool = exiftool
else:
self._exiftool = get_exiftool_path()
self._process_running = False
self._start_proc()
@property
def process(self):
""" return the exiftool subprocess """
if self._process_running:
return self._process
else:
raise ValueError("exiftool process is not running")
@property
def pid(self):
""" return process id (PID) of the exiftool process """
return self._process.pid
@property
def exiftool(self):
""" return path to exiftool process """
return self._exiftool
def _start_proc(self):
""" start exiftool in batch mode """
if self._process_running:
logging.warning("exiftool already running: {self._process}")
return
# open exiftool process
self._process = subprocess.Popen(
[
self._exiftool,
"-stay_open", # keep process open in batch mode
"True", # -stay_open=True, keep process open in batch mode
"-@", # read command-line arguments from file
"-", # read from stdin
"-common_args", # specifies args common to all commands subsequently run
"-n", # no print conversion (e.g. print tag values in machine readable format)
"-G", # print group name for each tag
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
self._process_running = True
def _stop_proc(self):
""" stop the exiftool process if it's running, otherwise, do nothing """
if not self._process_running:
logging.warning("exiftool process is not running")
return
self._process.stdin.write(b"-stay_open\n")
self._process.stdin.write(b"False\n")
self._process.stdin.flush()
try:
self._process.communicate(timeout=5)
except subprocess.TimeoutExpired:
logging.warning(
f"exiftool pid {self._process.pid} did not exit, killing it"
)
self._process.kill()
self._process.communicate()
del self._process
self._process_running = False
def __del__(self):
self._stop_proc()
class ExifTool:
""" Basic exiftool interface for reading and writing EXIF tags """
def __init__(self, filepath, exiftool=None, overwrite=True):
""" Return ExifTool object
file: path to image file
exiftool: path to exiftool, if not specified will look in path
overwrite: if True, will overwrite image file without creating backup, default=False """
self.file = filepath
self.overwrite = overwrite
self.data = {}
self._exiftoolproc = _ExifToolProc(exiftool=exiftool)
self._process = self._exiftoolproc.process
self._read_exif()
def setvalue(self, tag, value):
""" Set tag to value(s)
if value is None, will delete tag """
if value is None:
value = ""
command = []
command.append(f"-{tag}={value}")
if self.overwrite:
command.append("-overwrite_original")
self.run_commands(*command)
def addvalues(self, tag, *values):
""" Add one or more value(s) to tag
If more than one value is passed, each value will be added to the tag
Notes: exiftool may add duplicate values for some tags so the caller must ensure
the values being added are not already in the EXIF data
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
It's up to the caller to know what exiftool will do for each tag
If setvalue called before addvalues, exiftool does not appear to add duplicates,
but if addvalues called without first calling setvalue, exiftool will add duplicate values
"""
if not values:
raise ValueError("Must pass at least one value")
command = []
for value in values:
if value is None:
raise ValueError("Can't add None value to tag")
command.append(f"-{tag}+={value}")
if self.overwrite:
command.append("-overwrite_original")
if command:
self.run_commands(*command)
def run_commands(self, *commands, no_file=False):
""" run commands in the exiftool process and return result
no_file: (bool) do not pass the filename to exiftool (default=False)
by default, all commands will be run against self.file
use no_file=True to run a command without passing the filename """
if not hasattr(self, "_process") or not self._process:
raise ValueError("exiftool process is not running")
if not commands:
raise TypeError("must provide one or more command to run")
filename = os.fsencode(self.file) if not no_file else b""
command_str = (
b"\n".join([c.encode("utf-8") for c in commands])
+ b"\n"
+ filename
+ b"\n"
+ b"-execute\n"
)
if _debug():
logging.debug(command_str)
# send the command
self._process.stdin.write(command_str)
self._process.stdin.flush()
# read the output
output = b""
while EXIFTOOL_STAYOPEN_EOF not in str(output):
output += self._process.stdout.readline().strip()
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN]
@property
def pid(self):
""" return process id (PID) of the exiftool process """
return self._process.pid
@property
def version(self):
""" returns exiftool version """
ver = self.run_commands("-ver", no_file=True)
return ver.decode("utf-8")
def json(self):
""" return JSON dictionary from exiftool as dict """
json_str = self.run_commands("-json")
if json_str:
return json.loads(json_str)
else:
return None
def _read_exif(self):
""" read exif data from file """
json = self.json()
self.data = {k: v for k, v in json[0].items()}
def __str__(self):
str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
return str_

File diff suppressed because it is too large Load Diff

993
osxphotos/photoinfo.py Normal file
View File

@@ -0,0 +1,993 @@
"""
PhotoInfo class
Represents a single photo in the Photos library and provides access to the photo's attributes
PhotosDB.photos() returns a list of PhotoInfo objects
"""
import glob
import json
import logging
import os.path
import pathlib
import re
import subprocess
import sys
from datetime import timedelta, timezone
from pprint import pformat
import yaml
from mako.template import Template
from ._constants import (
_MOVIE_TYPE,
_PHOTO_TYPE,
_PHOTOS_5_SHARED_PHOTO_PATH,
_PHOTOS_5_VERSION,
_TEMPLATE_DIR,
_XMP_TEMPLATE_NAME,
)
from .exiftool import ExifTool
from .placeinfo import PlaceInfo4, PlaceInfo5
from .utils import (
_copy_file,
_export_photo_uuid_applescript,
_get_resource_loc,
dd_to_dms_str,
)
class PhotoInfo:
"""
Info about a specific photo, contains all the details about the photo
including keywords, persons, albums, uuid, path, etc.
"""
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
self._info = info
self._db = db
@property
def filename(self):
""" filename of the picture """
return self._info["filename"]
@property
def original_filename(self):
""" original filename of the picture
Photos 5 mangles filenames upon import """
return self._info["originalFilename"]
@property
def date(self):
""" image creation date as timezone aware datetime object """
imagedate = self._info["imageDate"]
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
@property
def date_modified(self):
""" image modification date as timezone aware datetime object
or None if no modification date set """
imagedate = self._info["lastmodifieddate"]
if imagedate:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
else:
return None
@property
def tzoffset(self):
""" timezone offset from UTC in seconds """
return self._info["imageTimeZoneOffsetSeconds"]
@property
def path(self):
""" absolute path on disk of the original picture """
photopath = None
if self._info["isMissing"] == 1:
return photopath # path would be meaningless until downloaded
if self._db._db_version < _PHOTOS_5_VERSION:
vol = self._info["volume"]
if vol is not None:
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
else:
photopath = os.path.join(
self._db._masters_path, self._info["imagePath"]
)
return photopath
# TODO: Is there a way to use applescript or PhotoKit to force the download in this
if self._info["shared"]:
# shared photo
photopath = os.path.join(
self._db._library_path,
_PHOTOS_5_SHARED_PHOTO_PATH,
self._info["directory"],
self._info["filename"],
)
return photopath
if self._info["directory"].startswith("/"):
photopath = os.path.join(self._info["directory"], self._info["filename"])
else:
photopath = os.path.join(
self._db._masters_path, self._info["directory"], self._info["filename"]
)
return photopath
@property
def path_edited(self):
""" absolute path on disk of the edited picture """
""" None if photo has not been edited """
# TODO: break this code into a _path_edited_4 and _path_edited_5
# version to simplify the big if/then; same for path_live_photo
photopath = None
if self._db._db_version < _PHOTOS_5_VERSION:
if self._info["hasAdjustments"]:
edit_id = self._info["edit_resource_id"]
if edit_id is not None:
library = self._db._library_path
folder_id, file_id = _get_resource_loc(edit_id)
# todo: is this always true or do we need to search file file_id under folder_id
# figure out what kind it is and build filename
filename = None
if self._info["type"] == _PHOTO_TYPE:
# it's a photo
filename = f"fullsizeoutput_{file_id}.jpeg"
elif self._info["type"] == _MOVIE_TYPE:
# it's a movie
filename = f"fullsizeoutput_{file_id}.mov"
else:
# don't know what it is!
logging.debug(f"WARNING: unknown type {self._info['type']}")
return None
# photopath appears to usually be in "00" subfolder but
# could be elsewhere--I haven't figured out this logic yet
# first see if it's in 00
photopath = os.path.join(
library,
"resources",
"media",
"version",
folder_id,
"00",
filename,
)
if not os.path.isfile(photopath):
rootdir = os.path.join(
library, "resources", "media", "version", folder_id
)
for dirname, _, filelist in os.walk(rootdir):
if filename in filelist:
photopath = os.path.join(dirname, filename)
break
# check again to see if we found a valid file
if not os.path.isfile(photopath):
logging.debug(
f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
logging.debug(
f"{self.uuid} hasAdjustments but edit_resource_id is None"
)
photopath = None
else:
photopath = None
# if self._info["isMissing"] == 1:
# photopath = None # path would be meaningless until downloaded
else:
# in Photos 5.0 / Catalina / MacOS 10.15:
# edited photos appear to always be converted to .jpeg and stored in
# library_name/resources/renders/X/UUID_1_201_a.jpeg
# where X = first letter of UUID
# and UUID = UUID of image
# this seems to be true even for photos not copied to Photos library and
# where original format was not jpg/jpeg
# if more than one edit, previous edit is stored as UUID_p.jpeg
if self._info["hasAdjustments"]:
library = self._db._library_path
directory = self._uuid[0] # first char of uuid
filename = None
if self._info["type"] == _PHOTO_TYPE:
# it's a photo
filename = f"{self._uuid}_1_201_a.jpeg"
elif self._info["type"] == _MOVIE_TYPE:
# it's a movie
filename = f"{self._uuid}_2_0_a.mov"
else:
# don't know what it is!
logging.debug(f"WARNING: unknown type {self._info['type']}")
return None
photopath = os.path.join(
library, "resources", "renders", directory, filename
)
if not os.path.isfile(photopath):
logging.debug(
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
photopath = None
# TODO: might be possible for original/master to be missing but edit to still be there
# if self._info["isMissing"] == 1:
# photopath = None # path would be meaningless until downloaded
logging.debug(photopath)
return photopath
@property
def description(self):
""" long / extended description of picture """
return self._info["extendedDescription"]
@property
def persons(self):
""" list of persons in picture """
return self._info["persons"]
@property
def albums(self):
""" list of albums picture is contained in """
albums = []
for album in self._info["albums"]:
albums.append(self._db._dbalbum_details[album]["title"])
return albums
@property
def keywords(self):
""" list of keywords for picture """
return self._info["keywords"]
@property
def title(self):
""" name / title of picture """
return self._info["name"]
@property
def uuid(self):
""" UUID of picture """
return self._uuid
@property
def ismissing(self):
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
do not immediately get written to disk. In particular, I've noticed that downloading
an image from the cloud does not force the database to be updated until something else
e.g. an edit, keyword, etc. occurs forcing a database synch
The exact process / timing is a mystery to be but be aware that if some photos were recently
downloaded from cloud to local storate their status in the database might still show
isMissing = 1
"""
return True if self._info["isMissing"] == 1 else False
@property
def hasadjustments(self):
""" True if picture has adjustments / edits """
return True if self._info["hasAdjustments"] == 1 else False
@property
def external_edit(self):
""" Returns True if picture was edited outside of Photos using external editor """
return (
True
if self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
else False
)
@property
def favorite(self):
""" True if picture is marked as favorite """
return True if self._info["favorite"] == 1 else False
@property
def hidden(self):
""" True if picture is hidden """
return True if self._info["hidden"] == 1 else False
@property
def location(self):
""" returns (latitude, longitude) as float in degrees or None """
return (self._latitude, self._longitude)
@property
def shared(self):
""" returns True if photos is in a shared iCloud album otherwise false
Only valid on Photos 5; returns None on older versions """
if self._db._db_version >= _PHOTOS_5_VERSION:
return self._info["shared"]
else:
return None
@property
def uti(self):
""" Returns Uniform Type Identifier (UTI) for the image
for example: public.jpeg or com.apple.quicktime-movie
"""
return self._info["UTI"]
@property
def ismovie(self):
""" Returns True if file is a movie, otherwise False
"""
return True if self._info["type"] == _MOVIE_TYPE else False
@property
def isphoto(self):
""" Returns True if file is an image, otherwise False
"""
return True if self._info["type"] == _PHOTO_TYPE else False
@property
def incloud(self):
""" Returns True if photo is cloud asset and is synched to cloud
False if photo is cloud asset and not yet synched to cloud
None if photo is not cloud asset
"""
return self._info["incloud"]
@property
def iscloudasset(self):
""" Returns True if photo is a cloud asset (in an iCloud library),
otherwise False
"""
if self._db._db_version < _PHOTOS_5_VERSION:
return (
True
if self._info["cloudLibraryState"] is not None
and self._info["cloudLibraryState"] != 0
else False
)
else:
return True if self._info["cloudAssetGUID"] is not None else False
@property
def burst(self):
""" Returns True if photo is part of a Burst photo set, otherwise False """
return self._info["burst"]
@property
def burst_photos(self):
""" If photo is a burst photo, returns list of PhotoInfo objects
that are part of the same burst photo set; otherwise returns empty list.
self is not included in the returned list """
if self._info["burst"]:
burst_uuid = self._info["burstUUID"]
burst_photos = [
PhotoInfo(db=self._db, uuid=u, info=self._db._dbphotos[u])
for u in self._db._dbphotos_burst[burst_uuid]
if u != self._uuid
]
return burst_photos
else:
return []
@property
def live_photo(self):
""" Returns True if photo is a live photo, otherwise False """
return self._info["live_photo"]
@property
def path_live_photo(self):
""" Returns path to the associated video file for a live photo
If photo is not a live photo, returns None
If photo is missing, returns None """
photopath = None
if self._db._db_version < _PHOTOS_5_VERSION:
if self.live_photo and not self.ismissing:
live_model_id = self._info["live_model_id"]
if live_model_id == None:
logging.debug(f"missing live_model_id: {self._uuid}")
photopath = None
else:
folder_id, file_id = _get_resource_loc(live_model_id)
library_path = self._db.library_path
photopath = os.path.join(
library_path,
"resources",
"media",
"master",
folder_id,
"00",
f"jpegvideocomplement_{file_id}.mov",
)
if not os.path.isfile(photopath):
# In testing, I've seen occasional missing movie for live photo
# These appear to be valid -- e.g. live component hasn't been downloaded from iCloud
# photos 4 has "isOnDisk" column we could check
# or could do the actual check with "isfile"
# TODO: should this be a warning or debug?
logging.debug(
f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
photopath = None
else:
# Photos 5
if self.live_photo and not self.ismissing:
filename = pathlib.Path(self.path)
photopath = filename.parent.joinpath(f"{filename.stem}_3.mov")
photopath = str(photopath)
if not os.path.isfile(photopath):
# In testing, I've seen occasional missing movie for live photo
# these appear to be valid -- e.g. video component not yet downloaded from iCloud
# TODO: should this be a warning or debug?
logging.debug(
f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
photopath = None
return photopath
@property
def panorama(self):
""" Returns True if photo is a panorama, otherwise False """
return self._info["panorama"]
@property
def slow_mo(self):
""" Returns True if photo is a slow motion video, otherwise False """
return self._info["slow_mo"]
@property
def time_lapse(self):
""" Returns True if photo is a time lapse video, otherwise False """
return self._info["time_lapse"]
@property
def hdr(self):
""" Returns True if photo is an HDR photo, otherwise False """
return self._info["hdr"]
@property
def screenshot(self):
""" Returns True if photo is an HDR photo, otherwise False """
return self._info["screenshot"]
@property
def portrait(self):
""" Returns True if photo is a portrait, otherwise False """
return self._info["portrait"]
@property
def selfie(self):
""" Returns True if photo is a selfie (front facing camera), otherwise False """
return self._info["selfie"]
@property
def place(self):
""" Returns PlaceInfo object containing reverse geolocation info """
# implementation note: doesn't create the PlaceInfo object until requested
# then memoizes the object in self._place to avoid recreating the object
if self._db._db_version < _PHOTOS_5_VERSION:
try:
return self._place # pylint: disable=access-member-before-definition
except AttributeError:
if self._info["placeNames"]:
self._place = PlaceInfo4(
self._info["placeNames"], self._info["countryCode"]
)
else:
self._place = None
return self._place
else:
try:
return self._place # pylint: disable=access-member-before-definition
except AttributeError:
if self._info["reverse_geolocation"]:
self._place = PlaceInfo5(self._info["reverse_geolocation"])
else:
self._place = None
return self._place
def export(
self,
dest,
*filename,
edited=False,
live_photo=False,
overwrite=False,
increment=True,
sidecar_json=False,
sidecar_xmp=False,
use_photos_export=False,
timeout=120,
exiftool=False,
):
""" export photo
dest: must be valid destination path (or exception raised)
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
export will print a warning but will happily export the photo using the
incorrect file extension. e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
sidecar filename will be dest/filename.json
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
returns list of full paths to the exported files """
# list of all files exported during this call to export
exported_files = []
# check edited and raise exception trying to export edited version of
# photo that hasn't been edited
if edited and not self.hasadjustments:
raise ValueError(
"Photo does not have adjustments, cannot export edited version"
)
# check arguments and get destination path and filename (if provided)
if filename and len(filename) > 2:
raise TypeError(
"Too many positional arguments. Should be at most two: destination, filename."
)
else:
# verify destination is a valid path
if dest is None:
raise ValueError("Destination must not be None")
elif not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
if filename and len(filename) == 1:
# if filename passed, use it
fname = filename[0]
else:
# no filename provided so use the default
# if edited file requested, use filename but add _edited
# need to use file extension from edited file as Photos saves a jpeg once edited
if edited and not use_photos_export:
# verify we have a valid path_edited and use that to get filename
if not self.path_edited:
raise FileNotFoundError(
"edited=True but path_edited is none; hasadjustments: "
f" {self.hasadjustments}"
)
edited_name = pathlib.Path(self.path_edited).name
edited_suffix = pathlib.Path(edited_name).suffix
fname = pathlib.Path(self.filename).stem + "_edited" + edited_suffix
else:
fname = self.filename
# check destination path
dest = pathlib.Path(dest)
fname = pathlib.Path(fname)
dest = dest / fname
# check extension of destination
if edited and self.path_edited is not None:
# use suffix from edited file
actual_suffix = pathlib.Path(self.path_edited).suffix
elif edited:
# use .jpeg as that's probably correct
# if edited and path_edited is None, will raise FileNotFoundError below
# unless use_photos_export is True
actual_suffix = ".jpeg"
else:
# use suffix from the non-edited file
actual_suffix = pathlib.Path(self.filename).suffix
# warn if suffixes don't match but ignore .JPG / .jpeg as
# Photo's often converts .JPG to .jpeg
suffixes = sorted([x.lower() for x in [dest.suffix, actual_suffix]])
if dest.suffix != actual_suffix and suffixes != [".jpeg", ".jpg"]:
logging.warning(
f"Invalid destination suffix: {dest.suffix}, should be {actual_suffix}"
)
# check to see if file exists and if so, add (1), (2), etc until we find one that works
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
# e.g. exporting sidecar for file1.png and file1.jpeg
# if file1.png exists and exporting file1.jpeg,
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
if increment and not overwrite:
count = 1
glob_str = str(dest.parent / f"{dest.stem}*")
dest_files = glob.glob(glob_str)
dest_files = [pathlib.Path(f).stem for f in dest_files]
dest_new = dest.stem
while dest_new in dest_files:
dest_new = f"{dest.stem} ({count})"
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not overwrite and not increment:
raise FileExistsError(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
if not use_photos_export:
# find the source file on disk and export
# get path to source file and verify it's not None and is valid file
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
if edited:
if self.path_edited is not None:
src = self.path_edited
else:
raise FileNotFoundError(
f"Cannot export edited photo if path_edited is None"
)
else:
if self.ismissing:
logging.warning(
f"Attempting to export photo with ismissing=True: path = {self.path}"
)
if self.path is not None:
src = self.path
else:
raise FileNotFoundError("Cannot export photo if path is None")
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}"
)
# copy the file, _copy_file uses ditto to preserve Mac extended attributes
_copy_file(src, dest)
exported_files.append(str(dest))
# copy live photo associated .mov if requested
if live_photo and self.live_photo:
live_name = dest.parent / f"{dest.stem}.mov"
src_live = self.path_live_photo
if src_live is not None:
logging.debug(
f"Exporting live photo video of {filename} as {live_name.name}"
)
_copy_file(src_live, str(live_name))
exported_files.append(str(live_name))
else:
logging.warning(f"Skipping missing live movie for {filename}")
else:
# use_photo_export
exported = None
# export live_photo .mov file?
live_photo = True if live_photo and self.live_photo else False
if edited:
# exported edited version and not original
if filename:
# use filename stem provided
filestem = dest.stem
else:
# didn't get passed a filename, add _edited
filestem = f"{dest.stem}_edited"
dest = dest.parent / f"{filestem}.jpeg"
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=False,
edited=True,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
)
else:
# export original version and not edited
filestem = dest.stem
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=True,
edited=False,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
)
if exported is not None:
exported_files.extend(exported)
else:
logging.warning(
f"Error exporting photo {self.uuid} to {dest} with use_photos_export"
)
if sidecar_json:
logging.debug("writing exiftool_json_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.json")
sidecar_str = self._exiftool_json_sidecar()
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
raise e
if sidecar_xmp:
logging.debug("writing xmp_sidecar")
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.xmp")
sidecar_str = self._xmp_sidecar()
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
raise e
# if exiftool, write the metadata
if exiftool and exported_files:
for exported_file in exported_files:
self._write_exif_data(exported_file)
return exported_files
def _write_exif_data(self, filepath):
""" write exif data to image file at filepath
filepath: full path to the image file """
if not os.path.exists(filepath):
raise FileNotFoundError(f"Could not find file {filepath}")
exiftool = ExifTool(filepath)
exif_info = json.loads(self._exiftool_json_sidecar())[0]
for exiftag, val in exif_info.items():
if type(val) == list:
# more than one, set first value the add additional values
exiftool.setvalue(exiftag, val.pop(0))
if val:
# add any remaining items
exiftool.addvalues(exiftag, *val)
else:
exiftool.setvalue(exiftag, val)
def _exiftool_json_sidecar(self):
""" return json string of EXIF details in exiftool sidecar format
Does not include all the EXIF fields as those are likely already in the image
Exports the following:
FileName
ImageDescription
Description
Title
TagsList
Keywords
Subject
PersonInImage
GPSLatitude, GPSLongitude
GPSPosition
GPSLatitudeRef, GPSLongitudeRef
DateTimeOriginal
OffsetTimeOriginal
ModifyDate """
exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
if self.description:
exif["EXIF:ImageDescription"] = self.description
exif["XMP:Description"] = self.description
if self.title:
exif["XMP:Title"] = self.title
if self.keywords:
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords)
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
exif["XMP:Subject"] = list(self.keywords)
if self.persons:
exif["XMP:PersonInImage"] = self.persons
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
if "XMP:Subject" in exif:
exif["XMP:Subject"].extend(self.persons)
else:
exif["XMP:Subject"] = self.persons
# if self.favorite():
# exif["Rating"] = 5
(lat, lon) = self.location
if lat is not None and lon is not None:
lat_str, lon_str = dd_to_dms_str(lat, lon)
exif["EXIF:GPSLatitude"] = lat_str
exif["EXIF:GPSLongitude"] = lon_str
exif["Composite:GPSPosition"] = f"{lat_str}, {lon_str}"
lat_ref = "North" if lat >= 0 else "South"
lon_ref = "East" if lon >= 0 else "West"
exif["EXIF:GPSLatitudeRef"] = lat_ref
exif["EXIF:GPSLongitudeRef"] = lon_ref
# process date/time and timezone offset
date = self.date
# exiftool expects format to "2015:01:18 12:00:00"
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
offsettime = date.strftime("%z")
# find timezone offset in format "-04:00"
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
offset = offset[0] # findall returns list of tuples
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
exif["EXIF:OffsetTimeOriginal"] = offsettime
if self.date_modified is not None:
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
json_str = json.dumps([exif])
return json_str
def _xmp_sidecar(self):
""" returns string for XMP sidecar """
# TODO: add additional fields to XMP file?
xmp_template = Template(
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)
)
xmp_str = xmp_template.render(photo=self)
# remove extra lines that mako inserts from template
xmp_str = "\n".join(
[line for line in xmp_str.split("\n") if line.strip() != ""]
)
return xmp_str
def _write_sidecar(self, filename, sidecar_str):
""" write sidecar_str to filename
used for exporting sidecar info """
if not filename and not sidecar_str:
raise (
ValueError(
f"filename {filename} and sidecar_str {sidecar_str} must not be None"
)
)
# TODO: catch exception?
f = open(filename, "w")
f.write(sidecar_str)
f.close()
@property
def _longitude(self):
""" Returns longitude, in degrees """
return self._info["longitude"]
@property
def _latitude(self):
""" Returns latitude, in degrees """
return self._info["latitude"]
def __repr__(self):
return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})"
def __str__(self):
""" string representation of PhotoInfo object """
date_iso = self.date.isoformat()
date_modified_iso = (
self.date_modified.isoformat() if self.date_modified else None
)
info = {
"uuid": self.uuid,
"filename": self.filename,
"original_filename": self.original_filename,
"date": date_iso,
"description": self.description,
"title": self.title,
"keywords": self.keywords,
"albums": self.albums,
"persons": self.persons,
"path": self.path,
"ismissing": self.ismissing,
"hasadjustments": self.hasadjustments,
"external_edit": self.external_edit,
"favorite": self.favorite,
"hidden": self.hidden,
"latitude": self._latitude,
"longitude": self._longitude,
"path_edited": self.path_edited,
"shared": self.shared,
"isphoto": self.isphoto,
"ismovie": self.ismovie,
"uti": self.uti,
"burst": self.burst,
"live_photo": self.live_photo,
"path_live_photo": self.path_live_photo,
"iscloudasset": self.iscloudasset,
"incloud": self.incloud,
"date_modified": date_modified_iso,
"portrait": self.portrait,
"screenshot": self.screenshot,
"slow_mo": self.slow_mo,
"time_lapse": self.time_lapse,
"hdr": self.hdr,
"selfie": self.selfie,
"panorama": self.panorama,
}
return yaml.dump(info, sort_keys=False)
def json(self):
""" return JSON representation """
date_modified_iso = (
self.date_modified.isoformat() if self.date_modified else None
)
pic = {
"uuid": self.uuid,
"filename": self.filename,
"original_filename": self.original_filename,
"date": self.date.isoformat(),
"description": self.description,
"title": self.title,
"keywords": self.keywords,
"albums": self.albums,
"persons": self.persons,
"path": self.path,
"ismissing": self.ismissing,
"hasadjustments": self.hasadjustments,
"external_edit": self.external_edit,
"favorite": self.favorite,
"hidden": self.hidden,
"latitude": self._latitude,
"longitude": self._longitude,
"path_edited": self.path_edited,
"shared": self.shared,
"isphoto": self.isphoto,
"ismovie": self.ismovie,
"uti": self.uti,
"burst": self.burst,
"live_photo": self.live_photo,
"path_live_photo": self.path_live_photo,
"iscloudasset": self.iscloudasset,
"incloud": self.incloud,
"date_modified": date_modified_iso,
"portrait": self.portrait,
"screenshot": self.screenshot,
"slow_mo": self.slow_mo,
"time_lapse": self.time_lapse,
"hdr": self.hdr,
"selfie": self.selfie,
"panorama": self.panorama,
}
return json.dumps(pic)
# compare two PhotoInfo objects for equality
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.__dict__ == other.__dict__
return False
def __ne__(self, other):
return not self.__eq__(other)

2144
osxphotos/photosdb.py Normal file

File diff suppressed because it is too large Load Diff

626
osxphotos/placeinfo.py Normal file
View File

@@ -0,0 +1,626 @@
"""
PlaceInfo class
Provides reverse geolocation info for photos
See https://developer.apple.com/documentation/corelocation/clplacemark
for additional documentation on reverse geolocation data
"""
from abc import ABC, abstractmethod
from collections import namedtuple # pylint: disable=syntax-error
import yaml
from bpylist import archiver
# postal address information, returned by PlaceInfo.address
PostalAddress = namedtuple(
"PostalAddress",
[
"street",
"sub_locality",
"city",
"sub_administrative_area",
"state_province",
"postal_code",
"country",
"iso_country_code",
],
)
# PlaceNames tuple returned by PlaceInfo.names
# order of fields 0 - 17 is mapped to placeType value in
# PLRevGeoLocationInfo.mapInfo.sortedPlaceInfos
# field 18 is combined bodies of water (ocean + inland_water)
# and maps to Photos <= 4, RKPlace.type == 44
# (Photos <= 4 doesn't have ocean or inland_water types)
# The fields named "field0", etc. appear to be unused
PlaceNames = namedtuple(
"PlaceNames",
[
"field0",
"country", # The name of the country associated with the placemark.
"state_province", # administrativeArea, The state or province associated with the placemark.
"sub_administrative_area", # Additional administrative area information for the placemark.
"city", # locality, The city associated with the placemark.
"field5",
"additional_city_info", # subLocality, Additional city-level information for the placemark.
"ocean", # The name of the ocean associated with the placemark.
"area_of_interest", # areasOfInterest, The relevant areas of interest associated with the placemark.
"inland_water", # The name of the inland water body associated with the placemark.
"field10",
"region", # The geographic region associated with the placemark.
"sub_throughfare", # Additional street-level information for the placemark.
"field13",
"postal_code", # The postal code associated with the placemark.
"field15",
"field16",
"street_address", # throughfare, The street address associated with the placemark.
"body_of_water", # RKPlace.type == 44, appears to be any body of water (ocean or inland)
],
)
# The following classes represent Photo Library Reverse Geolocation Info as stored
# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
# These classes are used by bpylist.archiver to unarchive the serialized objects
class PLRevGeoLocationInfo:
""" The top level reverse geolocation object """
def __init__(
self,
addressString,
countryCode,
mapItem,
isHome,
compoundNames,
compoundSecondaryNames,
version,
geoServiceProvider,
postalAddress,
):
self.addressString = addressString
self.countryCode = countryCode
self.mapItem = mapItem
self.isHome = isHome
self.compoundNames = compoundNames
self.compoundSecondaryNames = compoundSecondaryNames
self.version = version
self.geoServiceProvider = geoServiceProvider
self.postalAddress = postalAddress
def __eq__(self, other):
for field in [
"addressString",
"countryCode",
"isHome",
"compoundNames",
"compoundSecondaryNames",
"version",
"geoServiceProvider",
"postalAddress",
]:
if getattr(self, field) != getattr(other, field):
return False
return True
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return f"addressString: {self.addressString}, countryCode: {self.countryCode}, isHome: {self.isHome}, mapItem: {self.mapItem}, postalAddress: {self.postalAddress}"
@staticmethod
def encode_archive(obj, archive):
archive.encode("addressString", obj.addressString)
archive.encode("countryCode", obj.countryCode)
archive.encode("mapItem", obj.mapItem)
archive.encode("isHome", obj.isHome)
archive.encode("compoundNames", obj.compoundNames)
archive.encode("compoundSecondaryNames", obj.compoundSecondaryNames)
archive.encode("version", obj.version)
archive.encode("geoServiceProvider", obj.geoServiceProvider)
archive.encode("postalAddress", obj.postalAddress)
@staticmethod
def decode_archive(archive):
addressString = archive.decode("addressString")
countryCode = archive.decode("countryCode")
mapItem = archive.decode("mapItem")
isHome = archive.decode("isHome")
compoundNames = archive.decode("compoundNames")
compoundSecondaryNames = archive.decode("compoundSecondaryNames")
version = archive.decode("version")
geoServiceProvider = archive.decode("geoServiceProvider")
postalAddress = archive.decode("postalAddress")
return PLRevGeoLocationInfo(
addressString,
countryCode,
mapItem,
isHome,
compoundNames,
compoundSecondaryNames,
version,
geoServiceProvider,
postalAddress,
)
class PLRevGeoMapItem:
""" Stores the list of place names, organized by area """
def __init__(self, sortedPlaceInfos, finalPlaceInfos):
self.sortedPlaceInfos = sortedPlaceInfos
self.finalPlaceInfos = finalPlaceInfos
def __eq__(self, other):
for field in ["sortedPlaceInfos", "finalPlaceInfos"]:
if getattr(self, field) != getattr(other, field):
return False
return True
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
sortedPlaceInfos = []
finalPlaceInfos = []
for place in self.sortedPlaceInfos:
sortedPlaceInfos.append(str(place))
for place in self.finalPlaceInfos:
finalPlaceInfos.append(str(place))
return (
f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
)
@staticmethod
def encode_archive(obj, archive):
archive.encode("sortedPlaceInfos", obj.sortedPlaceInfos)
archive.encode("finalPlaceInfos", obj.finalPlaceInfos)
@staticmethod
def decode_archive(archive):
sortedPlaceInfos = archive.decode("sortedPlaceInfos")
finalPlaceInfos = archive.decode("finalPlaceInfos")
return PLRevGeoMapItem(sortedPlaceInfos, finalPlaceInfos)
class PLRevGeoMapItemAdditionalPlaceInfo:
""" Additional info about individual places """
def __init__(self, area, name, placeType, dominantOrderType):
self.area = area
self.name = name
self.placeType = placeType
self.dominantOrderType = dominantOrderType
def __eq__(self, other):
for field in ["area", "name", "placeType", "dominantOrderType"]:
if getattr(self, field) != getattr(other, field):
return False
return True
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return f"area: {self.area}, name: {self.name}, placeType: {self.placeType}"
@staticmethod
def encode_archive(obj, archive):
archive.encode("area", obj.area)
archive.encode("name", obj.name)
archive.encode("placeType", obj.placeType)
archive.encode("dominantOrderType", obj.dominantOrderType)
@staticmethod
def decode_archive(archive):
area = archive.decode("area")
name = archive.decode("name")
placeType = archive.decode("placeType")
dominantOrderType = archive.decode("dominantOrderType")
return PLRevGeoMapItemAdditionalPlaceInfo(
area, name, placeType, dominantOrderType
)
class CNPostalAddress:
""" postal address for the reverse geolocation info """
def __init__(
self,
_ISOCountryCode,
_city,
_country,
_postalCode,
_state,
_street,
_subAdministrativeArea,
_subLocality,
):
self._ISOCountryCode = _ISOCountryCode
self._city = _city
self._country = _country
self._postalCode = _postalCode
self._state = _state
self._street = _street
self._subAdministrativeArea = _subAdministrativeArea
self._subLocality = _subLocality
def __eq__(self, other):
for field in [
"_ISOCountryCode",
"_city",
"_country",
"_postalCode",
"_state",
"_street",
"_subAdministrativeArea",
"_subLocality",
]:
if getattr(self, field) != getattr(other, field):
return False
return True
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return ", ".join(
map(
str,
[
self._street,
self._city,
self._subLocality,
self._subAdministrativeArea,
self._state,
self._postalCode,
self._country,
self._ISOCountryCode,
],
)
)
@staticmethod
def encode_archive(obj, archive):
archive.encode("_ISOCountryCode", obj._ISOCountryCode)
archive.encode("_country", obj._country)
archive.encode("_city", obj._city)
archive.encode("_postalCode", obj._postalCode)
archive.encode("_state", obj._state)
archive.encode("_street", obj._street)
archive.encode("_subAdministrativeArea", obj._subAdministrativeArea)
archive.encode("_subLocality", obj._subLocality)
@staticmethod
def decode_archive(archive):
_ISOCountryCode = archive.decode("_ISOCountryCode")
_country = archive.decode("_country")
_city = archive.decode("_city")
_postalCode = archive.decode("_postalCode")
_state = archive.decode("_state")
_street = archive.decode("_street")
_subAdministrativeArea = archive.decode("_subAdministrativeArea")
_subLocality = archive.decode("_subLocality")
return CNPostalAddress(
_ISOCountryCode,
_city,
_country,
_postalCode,
_state,
_street,
_subAdministrativeArea,
_subLocality,
)
# register the classes with bpylist.archiver
archiver.update_class_map({"CNPostalAddress": CNPostalAddress})
archiver.update_class_map(
{"PLRevGeoMapItemAdditionalPlaceInfo": PLRevGeoMapItemAdditionalPlaceInfo}
)
archiver.update_class_map({"PLRevGeoMapItem": PLRevGeoMapItem})
archiver.update_class_map({"PLRevGeoLocationInfo": PLRevGeoLocationInfo})
class PlaceInfo(ABC):
@property
@abstractmethod
def address_str(self):
pass
@property
@abstractmethod
def country_code(self):
pass
@property
@abstractmethod
def ishome(self):
pass
@property
@abstractmethod
def name(self):
pass
@property
@abstractmethod
def names(self):
pass
@property
@abstractmethod
def address(self):
pass
class PlaceInfo4(PlaceInfo):
""" Reverse geolocation place info for a photo (Photos <= 4) """
def __init__(self, place_names, country_code):
""" place_names: list of place name tuples in ascending order by area
tuple fields are: modelID, place name, place type, area, e.g.
[(5, "St James's Park", 45, 0),
(4, 'Westminster', 16, 22097376),
(3, 'London', 4, 1596146816),
(2, 'England', 2, 180406091776),
(1, 'United Kingdom', 1, 414681432064)]
country_code: two letter country code for the country
"""
self._place_names = place_names
self._country_code = country_code
self._process_place_info()
@property
def address_str(self):
return None
@property
def country_code(self):
return self._country_code
@property
def ishome(self):
return None
@property
def name(self):
return self._name
@property
def names(self):
return self._names
@property
def address(self):
return PostalAddress(None, None, None, None, None, None, None, None)
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
else:
return (
self._place_names == other._place_names
and self._country_code == other._country_code
)
def _process_place_info(self):
""" Process place_names to set self._name and self._names """
places = self._place_names
# build a dictionary where key is placetype
places_dict = {}
for p in places:
# places in format:
# [(5, "St James's Park", 45, 0), ]
# 0: modelID
# 1: name
# 2: type
# 3: area
try:
places_dict[p[2]].append((p[1], p[3]))
except KeyError:
places_dict[p[2]] = [(p[1], p[3])]
# build list to populate PlaceNames tuple
# initialize with empty lists for each field in PlaceNames
place_info = [[]] * 19
# add the place names sorted by area (ascending)
# in Photos <=4, possible place type values are:
# 45: areasOfInterest (The relevant areas of interest associated with the placemark.)
# 44: body of water (includes both inlandWater and ocean)
# 43: subLocality (Additional city-level information for the placemark.
# 16: locality (The city associated with the placemark.)
# 4: subAdministrativeArea (Additional administrative area information for the placemark.)
# 2: administrativeArea (The state or province associated with the placemark.)
# 1: country
# mapping = mapping from PlaceNames to field in places_dict
# PlaceNames fields map to the placeType value in Photos5 (0..17)
# but place type in Photos <=4 has different values
# hence (3, 4) means PlaceNames[3] = places_dict[4] (sub_administrative_area)
mapping = [(1, 1), (2, 2), (3, 4), (4, 16), (18, 44), (8, 45)]
for field5, field4 in mapping:
try:
place_info[field5] = [
p[0]
for p in sorted(places_dict[field4], key=lambda place: place[1])
]
except KeyError:
pass
place_names = PlaceNames(*place_info)
self._names = place_names
# build the name as it appears in Photos
# the length of the name is at most 3 fields and appears to be based on available
# reverse geolocation data in the following order (left to right, joined by ',')
# always has country if available then either area of interest and city OR
# city and state
# e.g. 4, 2, 1 OR 8, 4, 1
# 8 (45): area_of_interest
# 4 (16): locality / city
# 2 (2): administrative area (state/province)
# 1 (1): country
name_list = []
if place_names[8]:
name_list.append(place_names[8][0])
if place_names[4]:
name_list.append(place_names[4][0])
elif place_names[4]:
name_list.append(place_names[4][0])
if place_names[2]:
name_list.append(place_names[2][0])
elif place_names[2]:
name_list.append(place_names[2][0])
# add country
if place_names[1]:
name_list.append(place_names[1][0])
name = ", ".join(name_list)
self._name = name if name != "" else None
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
info = {
"name": self.name,
"names": self.names,
"country_code": self.country_code,
}
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
return strval
class PlaceInfo5(PlaceInfo):
""" Reverse geolocation place info for a photo (Photos >= 5) """
def __init__(self, revgeoloc_bplist):
""" revgeoloc_bplist: a binary plist blob containing
a serialized PLRevGeoLocationInfo object """
self._bplist = revgeoloc_bplist
# todo: check for None?
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
self._process_place_info()
@property
def address_str(self):
""" returns the postal address as a string """
return self._plrevgeoloc.addressString
@property
def country_code(self):
""" returns the country code """
return self._plrevgeoloc.countryCode
@property
def ishome(self):
""" returns True if place is user's home address """
return self._plrevgeoloc.isHome
@property
def name(self):
""" returns local place name """
return self._name
@property
def names(self):
""" returns PlaceNames tuple with detailed reverse geolocation place names """
return self._names
@property
def address(self):
addr = self._plrevgeoloc.postalAddress
address = PostalAddress(
street=addr._street,
sub_locality=addr._subLocality,
city=addr._city,
sub_administrative_area=addr._subAdministrativeArea,
state_province=addr._state,
postal_code=addr._postalCode,
country=addr._country,
iso_country_code=addr._ISOCountryCode,
)
return address
def _process_place_info(self):
""" Process sortedPlaceInfos to set self._name and self._names """
places = self._plrevgeoloc.mapItem.sortedPlaceInfos
# build a dictionary where key is placetype
places_dict = {}
for p in places:
try:
places_dict[p.placeType].append((p.name, p.area))
except KeyError:
places_dict[p.placeType] = [(p.name, p.area)]
# build list to populate PlaceNames tuple
place_info = []
for field in range(18):
try:
# add the place names sorted by area (ascending)
place_info.append(
[
p[0]
for p in sorted(places_dict[field], key=lambda place: place[1])
]
)
except:
place_info.append([])
# fill in body_of_water for compatibility with Photos <= 4
place_info.append(place_info[7] + place_info[9])
place_names = PlaceNames(*place_info)
self._names = place_names
# build the name as it appears in Photos
# the length of the name is variable and appears to be based on available
# reverse geolocation data in the following order (left to right, joined by ',')
# 8: area_of_interest
# 11: region (I've only seen this applied to islands)
# 4: locality / city
# 2: administrative area (state/province)
# 1: country
# 9: inland_water
# 7: ocean
name = ", ".join(
[
p[0]
for p in [
place_names[8], # area of interest
place_names[11], # region (I've only seen this applied to islands)
place_names[4], # locality / city
place_names[2], # administrative area (state/province)
place_names[1], # country
place_names[9], # inland_water
place_names[7], # ocean
]
if p and p[0]
]
)
self._name = name if name != "" else None
def __eq__(self, other):
if not isinstance(other, type(self)):
return False
else:
return self._plrevgeoloc == other._plrevgeoloc
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
info = {
"name": self.name,
"names": self.names,
"country_code": self.country_code,
"ishome": self.ishome,
"address_str": self.address_str,
"address": str(self.address),
}
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
return strval

320
osxphotos/template.py Normal file
View File

@@ -0,0 +1,320 @@
import datetime
import pathlib
import re
from typing import Tuple # pylint: disable=syntax-error
from .photoinfo import PhotoInfo
TEMPLATE_SUBSTITUTIONS = {
"{name}": "Filename of the photo",
"{original_name}": "Photo's original filename when imported to Photos",
"{title}": "Title of the photo",
"{descr}": "Description of the photo",
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
"{created.year}": "4-digit year of file creation time",
"{created.yy}": "2-digit year of file creation time",
"{created.mm}": "2-digit month of the file creation time (zero padded)",
"{created.month}": "Month name in user's locale of the file creation time",
"{created.mon}": "Month abbreviation in the user's locale of the file creation time",
"{created.doy}": "3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)",
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
"{modified.year}": "4-digit year of file modification time",
"{modified.yy}": "2-digit year of file modification time",
"{modified.mm}": "2-digit month of the file modification time (zero padded)",
"{modified.month}": "Month name in user's locale of the file modification time",
"{modified.mon}": "Month abbreviation in the user's locale of the file modification time",
"{modified.doy}": "3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)",
"{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
"{place.country_code}": "The ISO country code from the photo's reverse geolocationo data",
"{place.name.country}": "Country name from the photo's reverse geolocation data",
"{place.name.state_province}": "State or province name from the photo's reverse geolocation data",
"{place.name.city}": "City or locality name from the photo's reverse geolocation data",
"{place.name.area_of_interest}": "Area of interest name (e.g. landmark or public place) from the photo's reverse geolocation data",
"{place.address}": "Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States'",
"{place.address.street}": "Street part of the postal address, e.g. '2007 18th St NW'",
"{place.address.city}": "City part of the postal address, e.g. 'Washington'",
"{place.address.state_province}": "State/province part of the postal address, e.g. 'DC'",
"{place.address.postal_code}": "Postal code part of the postal address, e.g. '20009'",
"{place.address.country}": "Country name of the postal address, e.g. 'United States'",
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
}
def get_template_value(lookup, photo):
""" lookup: value to find a match for
photo: PhotoInfo object whose data will be used for value substitutions
returns: either the matching template value (which may be None)
raises: KeyError if no rule exists for lookup """
# must be a valid keyword
if lookup == "name":
return pathlib.Path(photo.filename).stem
if lookup == "original_name":
return pathlib.Path(photo.original_filename).stem
if lookup == "title":
return photo.title
if lookup == "descr":
return photo.description
if lookup == "created.date":
return DateTimeFormatter(photo.date).date
if lookup == "created.year":
return DateTimeFormatter(photo.date).year
if lookup == "created.yy":
return DateTimeFormatter(photo.date).yy
if lookup == "created.mm":
return DateTimeFormatter(photo.date).mm
if lookup == "created.month":
return DateTimeFormatter(photo.date).month
if lookup == "created.mon":
return DateTimeFormatter(photo.date).mon
if lookup == "created.doy":
return DateTimeFormatter(photo.date).doy
if lookup == "modified.date":
return (
DateTimeFormatter(photo.date_modified).date if photo.date_modified else None
)
if lookup == "modified.year":
return (
DateTimeFormatter(photo.date_modified).year if photo.date_modified else None
)
if lookup == "modified.yy":
return (
DateTimeFormatter(photo.date_modified).yy if photo.date_modified else None
)
if lookup == "modified.mm":
return (
DateTimeFormatter(photo.date_modified).mm if photo.date_modified else None
)
if lookup == "modified.month":
return (
DateTimeFormatter(photo.date_modified).month
if photo.date_modified
else None
)
if lookup == "modified.mon":
return (
DateTimeFormatter(photo.date_modified).mon if photo.date_modified else None
)
if lookup == "modified.doy":
return (
DateTimeFormatter(photo.date_modified).doy if photo.date_modified else None
)
if lookup == "place.name":
return photo.place.name if photo.place else None
if lookup == "place.country_code":
return photo.place.country_code if photo.place else None
if lookup == "place.name.country":
return (
photo.place.names.country[0]
if photo.place and photo.place.names.country
else None
)
if lookup == "place.name.state_province":
return (
photo.place.names.state_province[0]
if photo.place and photo.place.names.state_province
else None
)
if lookup == "place.name.city":
return (
photo.place.names.city[0]
if photo.place and photo.place.names.city
else None
)
if lookup == "place.name.area_of_interest":
return (
photo.place.names.area_of_interest[0]
if photo.place and photo.place.names.area_of_interest
else None
)
if lookup == "place.address":
return (
photo.place.address_str if photo.place and photo.place.address_str else None
)
if lookup == "place.address.street":
return (
photo.place.address.street
if photo.place and photo.place.address.street
else None
)
if lookup == "place.address.city":
return (
photo.place.address.city
if photo.place and photo.place.address.city
else None
)
if lookup == "place.address.state_province":
return (
photo.place.address.state_province
if photo.place and photo.place.address.state_province
else None
)
if lookup == "place.address.postal_code":
return (
photo.place.address.postal_code
if photo.place and photo.place.address.postal_code
else None
)
if lookup == "place.address.country":
return (
photo.place.address.country
if photo.place and photo.place.address.country
else None
)
if lookup == "place.address.country_code":
return (
photo.place.address.iso_country_code
if photo.place and photo.place.address.iso_country_code
else None
)
# if here, didn't get a match
raise KeyError(f"No rule for processing {lookup}")
def render_filepath_template(
template: str, photo: PhotoInfo, none_str: str = "_"
) -> Tuple[str, list]:
""" render a filename or directory template """
# pylint: disable=anomalous-backslash-in-string
regex = r"""(?<!\\)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)\}"""
# pylint: disable=anomalous-backslash-in-string
unmatched_regex = r"(?<!\\)(\{[^\\,}]+\})"
# Explanation for regex:
# (?<!\\) Negative Lookbehind to skip escaped braces
# assert regex following does not match "\" preceeding "{"
# \{ Match the opening brace
# 1st Capturing Group ([^\\,}]+) Don't match "\", ",", or "}"
# 2nd Capturing Group (,?(([\w\-. ]+))?)
# ,{0,1} optional ","
# 3rd Capturing Group (([\w\-. ]+))?
# Matches the comma and any word characters after
# 4th Capturing Group ([\w\-. ]+)
# Matches just the characters after the comma
# \} Matches the closing brace
if type(template) is not str:
raise TypeError(f"template must be type str, not {type(template)}")
if type(photo) is not PhotoInfo:
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
def make_subst_function(photo, none_str):
""" returns: substitution function for use in re.sub """
# closure to capture photo, none_str in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 4:
try:
val = get_template_value(matchobj.group(1), photo)
except KeyError:
return matchobj.group(0)
if val is None:
return (
matchobj.group(3) if matchobj.group(3) is not None else none_str
)
else:
return val
else:
raise ValueError(
f"Unexpected number of groups: expected 4, got {groups}"
)
return subst
subst_func = make_subst_function(photo, none_str)
# do the replacements
rendered = re.sub(regex, subst_func, template)
# find any {words} that weren't replaced
unmatched = re.findall(unmatched_regex, rendered)
# fix any escaped curly braces
rendered = re.sub(r"\\{", "{", rendered)
rendered = re.sub(r"\\}", "}", rendered)
return rendered, unmatched
class DateTimeFormatter:
""" provides property access to formatted datetime.datetime strftime values """
def __init__(self, dt: datetime.datetime):
self.dt = dt
@property
def date(self):
""" ISO date in form 2020-03-22 """
date = self.dt.date().isoformat()
return date
@property
def year(self):
""" 4 digit year """
year = f"{self.dt.year}"
return year
@property
def yy(self):
""" 2 digit year """
yy = f"{self.dt.strftime('%y')}"
return yy
@property
def mm(self):
""" 2 digit month """
mm = f"{self.dt.strftime('%m')}"
return mm
@property
def month(self):
""" Month as locale's full name """
month = f"{self.dt.strftime('%B')}"
return month
@property
def mon(self):
""" Month as locale's abbreviated name """
mon = f"{self.dt.strftime('%b')}"
return mon
@property
def doy(self):
""" Julian day of year starting from 001 """
doy = f"{self.dt.strftime('%j')}"
return doy

View File

@@ -0,0 +1,99 @@
<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
<%def name="dc_description(desc)">
% if desc is None:
<dc:description></dc:description>
% else:
<dc:description>${desc}</dc:description>
% endif
</%def>
<%def name="dc_title(title)">
% if title is None:
<dc:title></dc:title>
% else:
<dc:title>${title}</dc:title>
% endif
</%def>
<%def name="dc_subject(subject)">
% if subject:
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
% for subj in subject:
<rdf:li>${subj}</rdf:li>
% endfor
</rdf:Seq>
</dc:subject>
% endif
</%def>
<%def name="dc_datecreated(date)">
% if date is not None:
<photoshop:DateCreated>${date.isoformat()}</photoshop:DateCreated>
% endif
</%def>
<%def name="iptc_personinimage(persons)">
% if persons:
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
% for person in persons:
<rdf:li>${person}</rdf:li>
% endfor
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
% endif
</%def>
<%def name="dk_tagslist(keywords)">
% if keywords:
<digiKam:TagsList>
<rdf:Seq>
% for keyword in keywords:
<rdf:li>${keyword}</rdf:li>
% endfor
</rdf:Seq>
</digiKam:TagsList>
% endif
</%def>
<%def name="adobe_createdate(date)">
% if date is not None:
<xmp:CreateDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:CreateDate>
% endif
</%def>
<%def name="adobe_modifydate(date)">
% if date is not None:
<xmp:ModifyDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:ModifyDate>
% endif
</%def>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
${dc_description(photo.description)}
${dc_title(photo.title)}
${dc_subject(photo.keywords + photo.persons)}
${dc_datecreated(photo.date)}
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
${iptc_personinimage(photo.persons)}
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
${dk_tagslist(photo.keywords)}
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
${adobe_createdate(photo.date)}
${adobe_modifydate(photo.date)}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>

459
osxphotos/utils.py Normal file
View File

@@ -0,0 +1,459 @@
import glob
import logging
import os.path
import pathlib
import platform
import sqlite3
import subprocess
import sys
import tempfile
import urllib.parse
from plistlib import load as plistload
import CoreFoundation
import objc
from Foundation import *
from osxphotos._applescript import AppleScript
_DEBUG = False
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(filename)s - %(lineno)d - %(message)s",
)
if not _DEBUG:
logging.disable(logging.DEBUG)
def _get_logger():
"""Used only for testing
Returns:
logging.Logger object -- logging.Logger object for osxphotos
"""
return logging.Logger(__name__)
def _set_debug(debug):
""" Enable or disable debug logging """
global _DEBUG
_DEBUG = debug
if debug:
logging.disable(logging.NOTSET)
else:
logging.disable(logging.DEBUG)
def _debug():
""" returns True if debugging turned on (via _set_debug), otherwise, false """
return _DEBUG
def _get_os_version():
# returns tuple containing OS version
# e.g. 10.13.6 = (10, 13, 6)
version = platform.mac_ver()[0].split(".")
if len(version) == 2:
(ver, major) = version
minor = "0"
elif len(version) == 3:
(ver, major, minor) = version
else:
raise (
ValueError(
f"Could not parse version string: {platform.mac_ver()} {version}"
)
)
return (ver, major, minor)
def _check_file_exists(filename):
""" returns true if file exists and is not a directory
otherwise returns false """
filename = os.path.abspath(filename)
return os.path.exists(filename) and not os.path.isdir(filename)
def _get_resource_loc(model_id):
""" returns folder_id and file_id needed to find location of edited photo """
""" and live photos for version <= Photos 4.0 """
# determine folder where Photos stores edited version
# edited images are stored in:
# Photos Library.photoslibrary/resources/media/version/XX/00/fullsizeoutput_Y.jpeg
# where XX and Y are computed based on RKModelResources.modelId
# file_id (Y in above example) is hex representation of model_id without leading 0x
file_id = hex_id = hex(model_id)[2:]
# folder_id (XX) in above example if first two chars of model_id converted to hex
# and left padded with zeros if < 4 digits
folder_id = hex_id.zfill(4)[0:2]
return folder_id, file_id
def _dd_to_dms(dd):
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
""" return tuple of int(deg), int(min), float(sec) """
dd = float(dd)
negative = dd < 0
dd = abs(dd)
min_, sec_ = divmod(dd * 3600, 60)
deg_, min_ = divmod(min_, 60)
if negative:
if deg_ > 0:
deg_ = deg_ * -1
elif min_ > 0:
min_ = min_ * -1
else:
sec_ = sec_ * -1
return int(deg_), int(min_), sec_
def _copy_file(src, dest):
""" Copies a file from src path to dest path
src: source path as string
dest: destination path as string
Uses ditto to perform copy; will silently overwrite dest if it exists
Raises exception if copy fails or either path is None """
if src is None or dest is None:
raise ValueError("src and dest must not be None", src, dest)
if not os.path.isfile(src):
raise ValueError("src file does not appear to exist", src)
# if error on copy, subprocess will raise CalledProcessError
try:
subprocess.run(
["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE
)
except subprocess.CalledProcessError as e:
logging.critical(
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
)
raise e
def dd_to_dms_str(lat, lon):
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
""" lat: latitude in degrees """
""" lon: longitude in degrees """
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
""" this is the same format used by exiftool's json format """
# TODO: add this to readme
lat_deg, lat_min, lat_sec = _dd_to_dms(lat)
lon_deg, lon_min, lon_sec = _dd_to_dms(lon)
lat_hemisphere = "N"
if any([lat_deg < 0, lat_min < 0, lat_sec < 0]):
lat_hemisphere = "S"
lon_hemisphere = "E"
if any([lon_deg < 0, lon_min < 0, lon_sec < 0]):
lon_hemisphere = "W"
lat_str = (
f"{abs(lat_deg)} deg {abs(lat_min)}' {abs(lat_sec):.2f}\" {lat_hemisphere}"
)
lon_str = (
f"{abs(lon_deg)} deg {abs(lon_min)}' {abs(lon_sec):.2f}\" {lon_hemisphere}"
)
return lat_str, lon_str
def get_system_library_path():
""" return the path to the system Photos library as string """
""" only works on MacOS 10.15+ """
""" on earlier versions, returns None """
_, major, _ = _get_os_version()
if int(major) < 15:
logging.debug(
f"get_system_library_path not implemented for MacOS < 10.15: you have {major}"
)
return None
plist_file = pathlib.Path(
str(pathlib.Path.home())
+ "/Library/Containers/com.apple.photolibraryd/Data/Library/Preferences/com.apple.photolibraryd.plist"
)
if plist_file.is_file():
with open(plist_file, "rb") as fp:
pl = plistload(fp)
else:
logging.warning(f"could not find plist file: {str(plist_file)}")
return None
photospath = pl["SystemLibraryPath"]
if photospath is not None:
return photospath
else:
logging.warning("Could not get path to Photos database")
return None
def get_last_library_path():
""" returns the path to the last opened Photos library
If a library has never been opened, returns None """
plist_file = pathlib.Path(
str(pathlib.Path.home())
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
)
if plist_file.is_file():
with open(plist_file, "rb") as fp:
pl = plistload(fp)
else:
logging.debug(f"could not find plist file: {str(plist_file)}")
return None
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
# this is a serialized CFData object
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
if photosurlref is not None:
# use CFURLCreateByResolvingBookmarkData to de-serialize bookmark data into a CFURLRef
# pylint: disable=no-member
# pylint: disable=undefined-variable
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
)
# the CFURLRef we got is a sruct that python treats as an array
# I'd like to pass this to CFURLGetFileSystemRepresentation to get the path but
# CFURLGetFileSystemRepresentation barfs when it gets an array from python instead of expected struct
# first element is the path string in form:
# file:///Users/username/Pictures/Photos%20Library.photoslibrary/
photosurlstr = photosurl[0].absoluteString() if photosurl[0] else None
# now coerce the file URI back into an OS path
# surely there must be a better way
if photosurlstr is not None:
photospath = os.path.normpath(
urllib.parse.unquote(urllib.parse.urlparse(photosurlstr).path)
)
else:
logging.warning(
"Could not extract photos URL String from IPXDefaultLibraryURLBookmark"
)
return None
return photospath
else:
logging.debug("Could not get path to Photos database")
return None
def list_photo_libraries():
""" returns list of Photos libraries found on the system """
""" on MacOS < 10.15, this may omit some libraries """
# On 10.15, mdfind appears to find all libraries
# On older MacOS versions, mdfind appears to ignore some libraries
# glob to find libraries in ~/Pictures then mdfind to find all the others
# TODO: make this more robust
lib_list = glob.glob(f"{str(pathlib.Path.home())}/Pictures/*.photoslibrary")
# On older OS, may not get all libraries so make sure we get the last one
last_lib = get_last_library_path()
if last_lib:
lib_list.append(last_lib)
output = subprocess.check_output(
["/usr/bin/mdfind", "-onlyin", "/", "-name", ".photoslibrary"]
).splitlines()
for lib in output:
lib_list.append(lib.decode("utf-8"))
lib_list = list(set(lib_list))
lib_list.sort()
return lib_list
def create_path_by_date(dest, dt):
""" Creates a path in dest folder in form dest/YYYY/MM/DD/
dest: valid path as str
dt: datetime.timetuple() object
Checks to see if path exists, if it does, do nothing and return path
If path does not exist, creates it and returns path"""
if not os.path.isdir(dest):
raise FileNotFoundError(f"dest {dest} must be valid path")
yyyy, mm, dd = dt[0:3]
yyyy = str(yyyy).zfill(4)
mm = str(mm).zfill(2)
dd = str(dd).zfill(2)
new_dest = os.path.join(dest, yyyy, mm, dd)
if not os.path.isdir(new_dest):
os.makedirs(new_dest)
return new_dest
# TODO: this doesn't always work, still looking for a way to
# force Photos to open the library being operated on
# def _open_photos_library_applescript(library_path):
# """ Force Photos to open a specific library
# library_path: path to the Photos library """
# open_scpt = AppleScript(
# f"""
# on openLibrary
# tell application "Photos"
# open POSIX file "{library_path}"
# end tell
# end openLibrary
# """
# )
# open_scpt.run()
def _export_photo_uuid_applescript(
uuid,
dest,
filestem=None,
original=True,
edited=False,
live_photo=False,
timeout=120,
burst=False,
):
""" Export photo to dest path using applescript to control Photos
If photo is a live photo, exports both the photo and associated .mov file
uuid: UUID of photo to export
dest: destination path to export to
filestem: (string) if provided, exported filename will be named stem.ext
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
If not provided, file will be named with whatever name Photos uses
If filestem.ext exists, it wil be overwritten
original: (boolean) if True, export original image; default = True
edited: (boolean) if True, export edited photo; default = False
If photo not edited and edited=True, will still export the original image
caller must verify image has been edited
*Note*: must be called with either edited or original but not both,
will raise error if called with both edited and original = True
live_photo: (boolean) if True, export associated .mov live photo; default = False
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
burst: (boolean) set to True if file is a burst image to avoid Photos export error
Returns: list of paths to exported file(s) or None if export failed
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
has not been edited. This is due to how Photos Applescript interface works.
"""
# setup the applescript to do the export
export_scpt = AppleScript(
"""
on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
tell application "Photos"
set thePath to thePath
set theItem to media item id theUUID
set theFilename to filename of theItem
set itemList to {theItem}
if original then
with timeout of theTimeOut seconds
export itemList to POSIX file thePath with using originals
end timeout
end if
if edited then
with timeout of theTimeOut seconds
export itemList to POSIX file thePath
end timeout
end if
return theFilename
end tell
end export_by_uuid
"""
)
dest = pathlib.Path(dest)
if not dest.is_dir:
raise ValueError(f"dest {dest} must be a directory")
if not original ^ edited:
raise ValueError(f"edited or original must be True but not both")
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
# export original
filename = None
try:
filename = export_scpt.call(
"export_by_uuid", uuid, tmpdir.name, original, edited, timeout
)
except Exception as e:
logging.warning("Error exporting uuid %s: %s" % (uuid, str(e)))
return None
if filename is not None:
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
# TemporaryDirectory will cleanup on return
filename_stem = pathlib.Path(filename).stem
files = glob.glob(os.path.join(tmpdir.name, "*"))
exported_paths = []
for fname in files:
path = pathlib.Path(fname)
if len(files) > 1 and not live_photo and path.suffix.lower() == ".mov":
# it's the .mov part of live photo but not requested, so don't export
logging.debug(f"Skipping live photo file {path}")
continue
if len(files) > 1 and burst and path.stem != filename_stem:
# skip any burst photo that's not the one we asked for
logging.debug(f"Skipping burst photo file {path}")
continue
if filestem:
# rename the file based on filestem, keeping original extension
dest_new = dest / f"{filestem}{path.suffix}"
else:
# use the name Photos provided
dest_new = dest / path.name
logging.debug(f"exporting {path} to dest_new: {dest_new}")
_copy_file(str(path), str(dest_new))
exported_paths.append(str(dest_new))
return exported_paths
else:
return None
def _open_sql_file(dbname):
""" opens sqlite file dbname in read-only mode
returns tuple of (connection, cursor) """
try:
dbpath = pathlib.Path(dbname).resolve()
conn = sqlite3.connect(f"{dbpath.as_uri()}?mode=ro", timeout=1, uri=True)
c = conn.cursor()
except sqlite3.Error as e:
sys.exit(f"An error occurred opening sqlite file: {e.args[0]} {dbname}")
return (conn, c)
def _db_is_locked(dbname):
""" check to see if a sqlite3 db is locked
returns True if database is locked, otherwise False
dbname: name of database to test """
# first, check to see if lock file exists, if so, assume the file is locked
lock_name = f"{dbname}.lock"
if os.path.exists(lock_name):
logging.debug(f"{dbname} is locked")
return True
# no lock file so try to read from the database to see if it's locked
locked = None
try:
(conn, c) = _open_sql_file(dbname)
c.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;")
conn.close()
logging.debug(f"{dbname} is not locked")
locked = False
except:
logging.debug(f"{dbname} is locked")
locked = True
return locked

View File

@@ -1,122 +1,158 @@
altgraph==0.17
ansimarkup==1.4.0
appdirs==1.4.3
astroid==2.2.5
atomicwrites==1.3.0
attrs==19.1.0
better-exceptions-fork==0.2.1.post6
# bpylist2==2.0.3;python_version<"3.8"
https://github.com/RhetTbull/bpylist/releases/download/v2.0.3/bpylist2-2.0.3.tar.gz#egg=bpylist2;python_version<"3.8"
bpylist2==3.0.0;python_version>="3.8"
certifi==2019.3.9
Click==7.0
colorama==0.4.1
importlib-metadata==0.18
coverage==4.5.4
importlib-metadata>=0.18
isort==4.3.20
lazy-object-proxy==1.4.1
loguru==0.2.5
macholib==1.14
Mako==1.1.1
MarkupSafe==1.1.1
mccabe==0.6.1
modulegraph==0.18
more-itertools==7.2.0
packaging==19.0
pathspec==0.7.0
pathvalidate==2.2.1
pluggy==0.12.0
py==1.8.0
py2app==0.21
Pygments==2.4.2
PyInstaller==3.6
pyinstaller-setuptools==2019.3
pylint==2.3.1
pyobjc==5.2
pyobjc-core==5.2
pyobjc-framework-Accounts==5.2
pyobjc-framework-AddressBook==5.2
pyobjc-framework-AdSupport==5.2
pyobjc-framework-AppleScriptKit==5.2
pyobjc-framework-AppleScriptObjC==5.2
pyobjc-framework-ApplicationServices==5.2
pyobjc-framework-Automator==5.2
pyobjc-framework-AVFoundation==5.2
pyobjc-framework-AVKit==5.2
pyobjc-framework-BusinessChat==5.2
pyobjc-framework-CalendarStore==5.2
pyobjc-framework-CFNetwork==5.2
pyobjc-framework-CloudKit==5.2
pyobjc-framework-Cocoa==5.2
pyobjc-framework-Collaboration==5.2
pyobjc-framework-ColorSync==5.2
pyobjc-framework-Contacts==5.2
pyobjc-framework-ContactsUI==5.2
pyobjc-framework-CoreAudio==5.2
pyobjc-framework-CoreAudioKit==5.2
pyobjc-framework-CoreBluetooth==5.2
pyobjc-framework-CoreData==5.2
pyobjc-framework-CoreLocation==5.2
pyobjc-framework-CoreMedia==5.2
pyobjc-framework-CoreMediaIO==5.2
pyobjc-framework-CoreML==5.2
pyobjc-framework-CoreServices==5.2
pyobjc-framework-CoreSpotlight==5.2
pyobjc-framework-CoreText==5.2
pyobjc-framework-CoreWLAN==5.2
pyobjc-framework-CryptoTokenKit==5.2
pyobjc-framework-DictionaryServices==5.2
pyobjc-framework-DiscRecording==5.2
pyobjc-framework-DiscRecordingUI==5.2
pyobjc-framework-DiskArbitration==5.2
pyobjc-framework-DVDPlayback==5.2
pyobjc-framework-EventKit==5.2
pyobjc-framework-ExceptionHandling==5.2
pyobjc-framework-ExternalAccessory==5.2
pyobjc-framework-FinderSync==5.2
pyobjc-framework-FSEvents==5.2
pyobjc-framework-GameCenter==5.2
pyobjc-framework-GameController==5.2
pyobjc-framework-GameKit==5.2
pyobjc-framework-GameplayKit==5.2
pyobjc-framework-ImageCaptureCore==5.2
pyobjc-framework-IMServicePlugIn==5.2
pyobjc-framework-InputMethodKit==5.2
pyobjc-framework-InstallerPlugins==5.2
pyobjc-framework-InstantMessage==5.2
pyobjc-framework-Intents==5.2
pyobjc-framework-IOSurface==5.2
pyobjc-framework-iTunesLibrary==5.2
pyobjc-framework-LatentSemanticMapping==5.2
pyobjc-framework-LaunchServices==5.2
pyobjc-framework-libdispatch==5.2
pyobjc-framework-LocalAuthentication==5.2
pyobjc-framework-MapKit==5.2
pyobjc-framework-MediaAccessibility==5.2
pyobjc-framework-MediaLibrary==5.2
pyobjc-framework-MediaPlayer==5.2
pyobjc-framework-MediaToolbox==5.2
pyobjc-framework-ModelIO==5.2
pyobjc-framework-MultipeerConnectivity==5.2
pyobjc-framework-NaturalLanguage==5.2
pyobjc-framework-NetFS==5.2
pyobjc-framework-Network==5.2
pyobjc-framework-NetworkExtension==5.2
pyobjc-framework-NotificationCenter==5.2
pyobjc-framework-OpenDirectory==5.2
pyobjc-framework-OSAKit==5.2
pyobjc-framework-Photos==5.2
pyobjc-framework-PhotosUI==5.2
pyobjc-framework-PreferencePanes==5.2
pyobjc-framework-PubSub==5.2
pyobjc-framework-QTKit==5.2
pyobjc-framework-Quartz==5.2
pyobjc-framework-SafariServices==5.2
pyobjc-framework-SceneKit==5.2
pyobjc-framework-ScreenSaver==5.2
pyobjc-framework-ScriptingBridge==5.2
pyobjc-framework-SearchKit==5.2
pyobjc-framework-Security==5.2
pyobjc-framework-SecurityFoundation==5.2
pyobjc-framework-SecurityInterface==5.2
pyobjc-framework-ServiceManagement==5.2
pyobjc-framework-Social==5.2
pyobjc-framework-SpriteKit==5.2
pyobjc-framework-StoreKit==5.2
pyobjc-framework-SyncServices==5.2
pyobjc-framework-SystemConfiguration==5.2
pyobjc-framework-UserNotifications==5.2
pyobjc-framework-VideoSubscriberAccount==5.2
pyobjc-framework-VideoToolbox==5.2
pyobjc-framework-Vision==5.2
pyobjc-framework-WebKit==5.2
pyobjc==6.0.1
pyobjc-core==6.0.1
pyobjc-framework-Accounts==6.0.1
pyobjc-framework-AddressBook==6.0.1
pyobjc-framework-AdSupport==6.0.1
pyobjc-framework-AppleScriptKit==6.0.1
pyobjc-framework-AppleScriptObjC==6.0.1
pyobjc-framework-ApplicationServices==6.0.1
pyobjc-framework-AuthenticationServices==6.0.1
pyobjc-framework-Automator==6.0.1
pyobjc-framework-AVFoundation==6.0.1
pyobjc-framework-AVKit==6.0.1
pyobjc-framework-BusinessChat==6.0.1
pyobjc-framework-CalendarStore==6.0.1
pyobjc-framework-CFNetwork==6.0.1
pyobjc-framework-CloudKit==6.0.1
pyobjc-framework-Cocoa==6.0.1
pyobjc-framework-Collaboration==6.0.1
pyobjc-framework-ColorSync==6.0.1
pyobjc-framework-Contacts==6.0.1
pyobjc-framework-ContactsUI==6.0.1
pyobjc-framework-CoreAudio==6.0.1
pyobjc-framework-CoreAudioKit==6.0.1
pyobjc-framework-CoreBluetooth==6.0.1
pyobjc-framework-CoreData==6.0.1
pyobjc-framework-CoreHaptics==6.0.1
pyobjc-framework-CoreLocation==6.0.1
pyobjc-framework-CoreMedia==6.0.1
pyobjc-framework-CoreMediaIO==6.0.1
pyobjc-framework-CoreML==6.0.1
pyobjc-framework-CoreMotion==6.0.1
pyobjc-framework-CoreServices==6.0.1
pyobjc-framework-CoreSpotlight==6.0.1
pyobjc-framework-CoreText==6.0.1
pyobjc-framework-CoreWLAN==6.0.1
pyobjc-framework-CryptoTokenKit==6.0.1
pyobjc-framework-DeviceCheck==6.0.1
pyobjc-framework-DictionaryServices==6.0.1
pyobjc-framework-DiscRecording==6.0.1
pyobjc-framework-DiscRecordingUI==6.0.1
pyobjc-framework-DiskArbitration==6.0.1
pyobjc-framework-DVDPlayback==6.0.1
pyobjc-framework-EventKit==6.0.1
pyobjc-framework-ExceptionHandling==6.0.1
pyobjc-framework-ExecutionPolicy==6.0.1
pyobjc-framework-ExternalAccessory==6.0.1
pyobjc-framework-FileProvider==6.0.1
pyobjc-framework-FileProviderUI==6.0.1
pyobjc-framework-FinderSync==6.0.1
pyobjc-framework-FSEvents==6.0.1
pyobjc-framework-GameCenter==6.0.1
pyobjc-framework-GameController==6.0.1
pyobjc-framework-GameKit==6.0.1
pyobjc-framework-GameplayKit==6.0.1
pyobjc-framework-ImageCaptureCore==6.0.1
pyobjc-framework-IMServicePlugIn==6.0.1
pyobjc-framework-InputMethodKit==6.0.1
pyobjc-framework-InstallerPlugins==6.0.1
pyobjc-framework-InstantMessage==6.0.1
pyobjc-framework-Intents==6.0.1
pyobjc-framework-IOSurface==6.0.1
pyobjc-framework-iTunesLibrary==6.0.1
pyobjc-framework-LatentSemanticMapping==6.0.1
pyobjc-framework-LaunchServices==6.0.1
pyobjc-framework-libdispatch==6.0.1
pyobjc-framework-LinkPresentation==6.0.1
pyobjc-framework-LocalAuthentication==6.0.1
pyobjc-framework-MapKit==6.0.1
pyobjc-framework-MediaAccessibility==6.0.1
pyobjc-framework-MediaLibrary==6.0.1
pyobjc-framework-MediaPlayer==6.0.1
pyobjc-framework-MediaToolbox==6.0.1
pyobjc-framework-MetalKit==6.0.1
pyobjc-framework-ModelIO==6.0.1
pyobjc-framework-MultipeerConnectivity==6.0.1
pyobjc-framework-NaturalLanguage==6.0.1
pyobjc-framework-NetFS==6.0.1
pyobjc-framework-Network==6.0.1
pyobjc-framework-NetworkExtension==6.0.1
pyobjc-framework-NotificationCenter==6.0.1
pyobjc-framework-OpenDirectory==6.0.1
pyobjc-framework-OSAKit==6.0.1
pyobjc-framework-OSLog==6.0.1
pyobjc-framework-PencilKit==6.0.1
pyobjc-framework-Photos==6.0.1
pyobjc-framework-PhotosUI==6.0.1
pyobjc-framework-PreferencePanes==6.0.1
pyobjc-framework-PubSub==6.0.1
pyobjc-framework-PushKit==6.0.1
pyobjc-framework-QTKit==6.0.1
pyobjc-framework-Quartz==6.0.1
pyobjc-framework-QuickLookThumbnailing==6.0.1
pyobjc-framework-SafariServices==6.0.1
pyobjc-framework-SceneKit==6.0.1
pyobjc-framework-ScreenSaver==6.0.1
pyobjc-framework-ScriptingBridge==6.0.1
pyobjc-framework-SearchKit==6.0.1
pyobjc-framework-Security==6.0.1
pyobjc-framework-SecurityFoundation==6.0.1
pyobjc-framework-SecurityInterface==6.0.1
pyobjc-framework-ServiceManagement==6.0.1
pyobjc-framework-Social==6.0.1
pyobjc-framework-SoundAnalysis==6.0.1
pyobjc-framework-Speech==6.0.1
pyobjc-framework-SpriteKit==6.0.1
pyobjc-framework-StoreKit==6.0.1
pyobjc-framework-SyncServices==6.0.1
pyobjc-framework-SystemConfiguration==6.0.1
pyobjc-framework-SystemExtensions==6.0.1
pyobjc-framework-UserNotifications==6.0.1
pyobjc-framework-VideoSubscriberAccount==6.0.1
pyobjc-framework-VideoToolbox==6.0.1
pyobjc-framework-Vision==6.0.1
pyobjc-framework-WebKit==6.0.1
pyparsing==2.4.1.1
PyYAML==5.1.2
regex==2020.2.20
six==1.12.0
termcolor==1.1.0
toml==0.10.0
typed-ast==1.4.1
wcwidth==0.1.7
wrapt==1.11.1
zipp==0.5.2

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# setup.py script for osxphotos
# setup.py script for osxphotos
#
# Copyright (c) 2019 Rhet Turnbull, rturnbull+git@gmail.com
# All rights reserved.
@@ -26,19 +26,22 @@
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# from distutils.core import setup
from setuptools import setup, find_packages
import os
from setuptools import find_packages, setup
# read the contents of README file
from os import path
this_directory = path.abspath(path.dirname(__file__))
with open(path.join(this_directory, "README.md"), encoding="utf-8") as f:
this_directory = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
long_description = f.read()
about = {}
with open(
os.path.join(this_directory, "osxphotos", "_version.py"), mode="r", encoding="utf-8"
) as f:
exec(f.read(), about)
setup(
name="osxphotos",
version="0.12.3",
version=about["__version__"],
description="Manipulate (read-only) Apple's Photos app library on Mac OS X",
long_description=long_description,
long_description_content_type="text/markdown",
@@ -47,7 +50,7 @@ setup(
url="https://github.com/RhetTbull/",
project_urls={"GitHub": "https://github.com/RhetTbull/osxphotos"},
download_url="https://github.com/RhetTbull/osxphotos",
packages=find_packages(exclude=["tests","examples"]),
packages=find_packages(exclude=["tests", "examples", "utils"]),
license="License :: OSI Approved :: MIT License",
classifiers=[
"Development Status :: 4 - Beta",
@@ -58,8 +61,15 @@ setup(
"Programming Language :: Python :: 3.6",
"Topic :: Software Development :: Libraries :: Python Modules",
],
install_requires=["pyobjc","Click","pyyaml",],
entry_points = {
'console_scripts' : ['osxphotos=osxphotos.cmd_line:cli'],
}
install_requires=[
"pyobjc>=6.0.1",
"Click>=7",
"PyYAML>=5.1.2",
"Mako>=1.1.1",
"bpylist2==2.0.3;python_version<'3.8'",
"bpylist2==3.0.0;python_version>='3.8'",
"pathvalidate==2.2.1",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,
)

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DatabaseMinorVersion</key>
<integer>1</integer>
<key>DatabaseVersion</key>
<integer>112</integer>
<key>LastOpenMode</key>
<integer>2</integer>
<key>LibrarySchemaVersion</key>
<integer>4025</integer>
<key>MetaSchemaVersion</key>
<integer>2</integer>
<key>createDate</key>
<date>2019-12-27T23:19:08Z</date>
</dict>
</plist>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Photos</key>
<dict>
<key>CollapsedSidebarSectionIdentifiers</key>
<array/>
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>TopLevelAlbums</string>
<string>TopLevelSlideshows</string>
</array>
<key>lastKnownItemCounts</key>
<dict>
<key>other</key>
<integer>0</integer>
<key>photos</key>
<integer>0</integer>
<key>videos</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-12-27T23:19:59Z</date>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PLLanguageAndLocaleKey</key>
<string>en-US:en_US</string>
<key>PLLastGeoProviderIdKey</key>
<string>7618</string>
<key>PLLastLocationInfoFormatVer</key>
<integer>12</integer>
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>53</integer>
<key>LibraryBuildTag</key>
<string>F176BAF5-4B7A-4878-83C4-4D4175F299BF</string>
<key>LibrarySchemaVersion</key>
<integer>4025</integer>
</dict>
</plist>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DatabaseMinorVersion</key>
<integer>1</integer>
<key>DatabaseVersion</key>
<integer>112</integer>
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>53</integer>
<key>LibraryBuildTag</key>
<string>F176BAF5-4B7A-4878-83C4-4D4175F299BF</string>
<key>LibrarySchemaVersion</key>
<integer>4025</integer>
</dict>
<key>LibrarySchemaVersion</key>
<integer>4025</integer>
<key>MetaSchemaVersion</key>
<integer>2</integer>
<key>SnapshotComplete</key>
<true/>
<key>SnapshotCompletedDate</key>
<date>2019-12-27T23:19:08Z</date>
<key>SnapshotLastValidated</key>
<date>2019-12-27T23:19:08Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>
</plist>

View File

@@ -17,5 +17,6 @@ Images used from:
- [Carlos Montesdeoca](https://www.flickr.com/photos/carlosmontesdeocastudio)
- [Rydale Clothing](https://www.flickr.com/photos/rydaleclothing)
- [Marco Verch](https://www.flickr.com/photos/30478819@N08/48228222317/)
- [K M](https://www.flickr.com/photos/153387643@N08/49334338022/)

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-08-24T02:50:48Z</date>
<date>2019-12-08T16:44:38Z</date>
</dict>
<key>PXPeopleScreenUnlocked</key>
<true/>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2019-08-24T02:51:33Z</date>
<date>2020-01-22T02:10:26Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-08-24T13:19:30Z</date>
<date>2020-01-22T02:10:27Z</date>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2019-08-24T02:51:30Z</date>
<date>2020-01-19T17:29:28Z</date>
</dict>
</plist>

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>403</integer>
<integer>414</integer>
<key>LibraryBuildTag</key>
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
<key>LibrarySchemaVersion</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>DatabaseMinorVersion</key>
<integer>1</integer>
<key>DatabaseVersion</key>
<integer>112</integer>
<key>LastOpenMode</key>
<integer>2</integer>
<key>LibrarySchemaVersion</key>
<integer>4025</integer>
<key>MetaSchemaVersion</key>
<integer>2</integer>
<key>createDate</key>
<date>2019-07-27T13:16:43Z</date>
</dict>
</plist>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Photos</key>
<dict>
<key>CollapsedSidebarSectionIdentifiers</key>
<array/>
<key>ExpandedSidebarItemIdentifiers</key>
<array>
<string>TopLevelAlbums</string>
<string>TopLevelSlideshows</string>
</array>
<key>IPXWorkspaceControllerZoomLevelsKey</key>
<dict>
<key>kZoomLevelIdentifierAlbums</key>
<integer>10</integer>
<key>kZoomLevelIdentifierVersions</key>
<integer>7</integer>
</dict>
<key>lastAddToDestination</key>
<dict>
<key>key</key>
<integer>1</integer>
<key>lastKnownDisplayName</key>
<string>Test Album (1)</string>
<key>type</key>
<string>album</string>
<key>uuid</key>
<string>Uq6qsKihRRSjMHTiD+0Azg</string>
</dict>
<key>lastKnownItemCounts</key>
<dict>
<key>other</key>
<integer>0</integer>
<key>photos</key>
<integer>6</integer>
<key>videos</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-01-11T16:41:00Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-01-12T06:02:45Z</date>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More