Compare commits

..

411 Commits

Author SHA1 Message Date
Rhet Turnbull
3bac106eb7 test library update 2020-04-18 12:24:03 -07:00
Rhet Turnbull
47d1c82c03 Added folder support for Photos <= 4, closes #93 2020-04-18 12:21:08 -07:00
Rhet Turnbull
6f281711e2 cleaned up SQL statements in _process_database4 2020-04-18 08:05:43 -07:00
Rhet Turnbull
4b30b3b426 Fixed suffix check on export to be case insensitive 2020-04-18 07:59:04 -07:00
Rhet Turnbull
1fa9583ea6 Updated CHANGELOG.md 2020-04-17 23:33:17 -07:00
Rhet Turnbull
235e1fb1a6 Updated README.md 2020-04-17 23:23:57 -07:00
Rhet Turnbull
36c2821a0f replaced CLI option --original-name with --current-name 2020-04-17 23:20:23 -07:00
Rhet Turnbull
ed425724a0 Changed default CLI behavior to export all photos 2020-04-17 22:52:11 -07:00
Rhet Turnbull
55daa31c71 Update README.md 2020-04-17 12:46:56 -07:00
Rhet Turnbull
b6ac9e1ea3 Updated test library for Sierra 2020-04-17 11:52:55 -07:00
Rhet Turnbull
9d151478d6 Initial support for RAW photos in Photos 4 to address issue #101 2020-04-17 10:44:15 -07:00
Rhet Turnbull
7d55844390 Added --export-raw to CLI export 2020-04-16 23:10:33 -07:00
Rhet Turnbull
f398e9116f Added --has-raw to CLI query and export 2020-04-16 16:46:29 -07:00
Rhet Turnbull
4fe8190b57 Added raw details to PhotoInfo json() and __str__() 2020-04-16 16:11:47 -07:00
Rhet Turnbull
7e42ebb240 Initial work on suppport for associated RAW images 2020-04-16 11:53:48 -07:00
Rhet Turnbull
edae116baa Test library update 2020-04-16 11:53:05 -07:00
Rhet Turnbull
d542cda17d Small fix to database version logic to look into issue #102 2020-04-15 14:09:47 -07:00
Rhet Turnbull
99b5b54c6d Update README.md 2020-04-15 11:38:57 -07:00
Rhet Turnbull
379feddcda Updated README.md to add Known Bugs section 2020-04-15 11:37:38 -07:00
Rhet Turnbull
24285f5dd2 Update README.md 2020-04-13 00:53:50 -07:00
Rhet Turnbull
3cb3ebb300 Updated CHANGELOG.md 2020-04-13 00:46:08 -07:00
Rhet Turnbull
16037f10fa Update README.md 2020-04-12 23:50:04 -07:00
Rhet Turnbull
ebd21491ac Updated examples to work with latest version 2020-04-12 20:57:37 -07:00
Rhet Turnbull
b7c7b9f066 Added {folder_album} to template and --folder to CLI 2020-04-12 14:53:53 -07:00
Rhet Turnbull
21e7020fec Test library update 2020-04-12 14:52:35 -07:00
Rhet Turnbull
952741d488 Updated CHANGELOG.md 2020-04-12 12:27:49 -07:00
Rhet Turnbull
9fef12ed37 Fixed bug with handling of deleted albums 2020-04-12 12:15:38 -07:00
Rhet Turnbull
97362fc0f1 Added additional tests for album_info 2020-04-12 09:22:14 -07:00
Rhet Turnbull
e09f0b40f1 Changed AlbumInfo and FolderInfo interface to maintain backwards compatibility with PhotosDB.albums 2020-04-12 09:01:16 -07:00
Rhet Turnbull
b749681c6d Updated CHANGELOG.md 2020-04-11 14:33:28 -07:00
Rhet Turnbull
8544667c72 Update README.md TOC 2020-04-11 14:20:05 -07:00
Rhet Turnbull
d6a22b765a Added tests and README for AlbumInfo and FolderInfo 2020-04-11 14:07:39 -07:00
Rhet Turnbull
96365728c2 Added albuminfo.py for AlbumInfo and FolderInfo classes 2020-04-11 10:28:50 -07:00
Rhet Turnbull
c01f713f00 Merge pull request #95 from jystervinou/patch-2
Update README.md
2020-04-11 06:53:58 -07:00
Jean-Yves Stervinou
1aa3838c38 Update README.md
just some typos fixes
- packge/package
- the copy then read can take => the copy read then can take
2020-04-11 11:34:17 +02:00
Rhet Turnbull
cde56e9d13 Updated CHANGELOG.md 2020-04-10 18:56:22 -07:00
Rhet Turnbull
1c9da5ed6f Bug fix for PhotosDB.photos() query 2020-04-10 18:50:58 -07:00
Rhet Turnbull
d74f7f499b Updated test library 2020-04-10 17:58:45 -07:00
Rhet Turnbull
c85bb02304 Updated CHANGELOG.md 2020-04-10 17:35:23 -07:00
Rhet Turnbull
3e5062684a Changed PhotosDB albums interface as prep for adding folders 2020-04-10 17:30:37 -07:00
Rhet Turnbull
626e460aab Update README.md 2020-04-06 07:24:20 -07:00
Rhet Turnbull
1820715849 Added test for 10.15.4 2020-04-05 22:57:05 -07:00
Rhet Turnbull
a6ca3f453c Updated CHANGELOG.md 2020-04-05 09:17:27 -07:00
Rhet Turnbull
ddaa66d19e Added --no-extended-attributes option to CLI, closes #85 2020-04-05 09:13:52 -07:00
Rhet Turnbull
6073acc9d3 Fixed CLI help for invalid topic, closes #76 2020-04-05 08:25:30 -07:00
Rhet Turnbull
bae0283441 Updated test library 2020-04-05 07:54:26 -07:00
Rhet Turnbull
507c4a3740 Added {album}, {keyword}, and {person} to template system 2020-04-04 13:58:54 -07:00
Rhet Turnbull
6a898886dd Updated render_filepath_template to support multiple values 2020-04-04 09:53:23 -07:00
Rhet Turnbull
01cd7fed6d Updated export example 2020-04-01 06:47:51 -07:00
Rhet Turnbull
e8273c9752 Added places, --place, --no-place to CLI, closes #87, #88 2020-03-31 20:39:13 -07:00
Rhet Turnbull
fd5e748dca Added places command to CLI 2020-03-29 23:03:12 -07:00
Rhet Turnbull
c02953ef5f Fixed typo in help text 2020-03-28 10:26:10 -07:00
Rhet Turnbull
daea30f162 Updated CHANGELOG.md 2020-03-28 10:21:10 -07:00
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
Rhet Turnbull
39ef8ddf3f fixed typo in README 2019-08-24 08:31:34 -07:00
1638 changed files with 24470 additions and 3643 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/

357
CHANGELOG.md Normal file
View File

@@ -0,0 +1,357 @@
### 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.28.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.4...v0.28.1)
> 18 April 2020
- Initial work on suppport for associated RAW images [`7e42ebb`](https://github.com/RhetTbull/osxphotos/commit/7e42ebb2402d45cd5d20bdd55bddddaa9db4679f)
- Initial support for RAW photos in Photos 4 to address issue #101 [`9d15147`](https://github.com/RhetTbull/osxphotos/commit/9d151478d610291b8d482aafae3d445dfd391fca)
- replaced CLI option --original-name with --current-name [`36c2821`](https://github.com/RhetTbull/osxphotos/commit/36c2821a0fa62eaaa54cf1edc2d9c6da98155354)
#### [v0.27.4](https://github.com/RhetTbull/osxphotos/compare/v0.27.3...v0.27.4)
> 12 April 2020
- Added {folder_album} to template and --folder to CLI [`b7c7b9f`](https://github.com/RhetTbull/osxphotos/commit/b7c7b9f0664e69c743bdd8a228ad2936cf6b7600)
- Test library update [`21e7020`](https://github.com/RhetTbull/osxphotos/commit/21e7020fec406b0f3926d7adc8a1451bfe77e75a)
- Updated CHANGELOG.md [`952741d`](https://github.com/RhetTbull/osxphotos/commit/952741d488d2fbbaf8a0c1d3781ad7c4205c068f)
#### [v0.27.3](https://github.com/RhetTbull/osxphotos/compare/v0.27.1...v0.27.3)
> 12 April 2020
- Added additional tests for album_info [`97362fc`](https://github.com/RhetTbull/osxphotos/commit/97362fc0f13b2867abc013f4ba97ae60b0700894)
- Fixed bug with handling of deleted albums [`9fef12e`](https://github.com/RhetTbull/osxphotos/commit/9fef12ed37634a7bdb11232976b4b2ddccd1a7cb)
#### [v0.27.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.0...v0.27.1)
> 12 April 2020
- Changed AlbumInfo and FolderInfo interface to maintain backwards compatibility with PhotosDB.albums [`e09f0b4`](https://github.com/RhetTbull/osxphotos/commit/e09f0b40f1671d70ee399cdc519492b04fac8adc)
- Updated CHANGELOG.md [`b749681`](https://github.com/RhetTbull/osxphotos/commit/b749681c6d2545eacf653ab1b2a5d1384e3123eb)
#### [v0.27.0](https://github.com/RhetTbull/osxphotos/compare/v0.26.1...v0.27.0)
> 11 April 2020
- Update README.md [`#95`](https://github.com/RhetTbull/osxphotos/pull/95)
- Added tests and README for AlbumInfo and FolderInfo [`d6a22b7`](https://github.com/RhetTbull/osxphotos/commit/d6a22b765ab17f6ef1ba8c50b77946f090979968)
- Added albuminfo.py for AlbumInfo and FolderInfo classes [`9636572`](https://github.com/RhetTbull/osxphotos/commit/96365728c2ff42abfb6828872ffac53b4c3c8024)
- Updated CHANGELOG.md [`cde56e9`](https://github.com/RhetTbull/osxphotos/commit/cde56e9d13baf3098ec85839cf1aaa33b4915ac9)
- Update README.md TOC [`8544667`](https://github.com/RhetTbull/osxphotos/commit/8544667c729ea0d7fe39671d909e09cda519e250)
#### [v0.26.1](https://github.com/RhetTbull/osxphotos/compare/v0.26.0...v0.26.1)
> 11 April 2020
- Bug fix for PhotosDB.photos() query [`1c9da5e`](https://github.com/RhetTbull/osxphotos/commit/1c9da5ed6ffa21f0577906b65b7da08951725d1f)
- Updated test library [`d74f7f4`](https://github.com/RhetTbull/osxphotos/commit/d74f7f499bf59f37ec81cfa9d49cbbf3aafb5961)
- Updated CHANGELOG.md [`c85bb02`](https://github.com/RhetTbull/osxphotos/commit/c85bb023042e072d6688060eb259156c2fa579b9)
#### [v0.26.0](https://github.com/RhetTbull/osxphotos/compare/v0.25.1...v0.26.0)
> 11 April 2020
- Added test for 10.15.4 [`1820715`](https://github.com/RhetTbull/osxphotos/commit/182071584904d001a9b199eef5febfb79e00696e)
- Changed PhotosDB albums interface as prep for adding folders [`3e50626`](https://github.com/RhetTbull/osxphotos/commit/3e5062684ab6d706d91d4abeb4e3b0ca47867b70)
- Updated CHANGELOG.md [`a6ca3f4`](https://github.com/RhetTbull/osxphotos/commit/a6ca3f453ce0fae4e8d13c7c256ed69a16d2e3f2)
#### [v0.25.1](https://github.com/RhetTbull/osxphotos/compare/v0.25.0...v0.25.1)
> 5 April 2020
- Added --no-extended-attributes option to CLI, closes #85 [`#85`](https://github.com/RhetTbull/osxphotos/issues/85)
- Fixed CLI help for invalid topic, closes #76 [`#76`](https://github.com/RhetTbull/osxphotos/issues/76)
- Updated test library [`bae0283`](https://github.com/RhetTbull/osxphotos/commit/bae0283441f04d71aa78dbd1cf014f376ef1f91a)
#### [v0.25.0](https://github.com/RhetTbull/osxphotos/compare/v0.24.2...v0.25.0)
> 4 April 2020
- Added places, --place, --no-place to CLI, closes #87, #88 [`#87`](https://github.com/RhetTbull/osxphotos/issues/87)
- Updated render_filepath_template to support multiple values [`6a89888`](https://github.com/RhetTbull/osxphotos/commit/6a898886ddadc9d5bc9dbad6ee7365270dd0a26d)
- Added {album}, {keyword}, and {person} to template system [`507c4a3`](https://github.com/RhetTbull/osxphotos/commit/507c4a374014f999ca19789bce0df0c14332e021)
- Added places command to CLI [`fd5e748`](https://github.com/RhetTbull/osxphotos/commit/fd5e748dca759ea1c3a7329d447f363afe8418b7)
- Updated export example [`01cd7fe`](https://github.com/RhetTbull/osxphotos/commit/01cd7fed6d7fc0c61c171a05319c211eb0a9f7c1)
- Updated CHANGELOG.md [`daea30f`](https://github.com/RhetTbull/osxphotos/commit/daea30f1626a208209ab6854cbd3b12f4b0a3405)
#### [v0.24.2](https://github.com/RhetTbull/osxphotos/compare/v0.24.1...v0.24.2)
> 28 March 2020
- added {place.country_code} to template system [`be2e167`](https://github.com/RhetTbull/osxphotos/commit/be2e16769d5d2c75af6d7792f1311f5a65c3bc67)
#### [v0.24.1](https://github.com/RhetTbull/osxphotos/compare/v0.23.4...v0.24.1)
> 28 March 2020
- Added detailed place data in PlaceInfo.names [`c06dd42`](https://github.com/RhetTbull/osxphotos/commit/c06dd4233f917f068c087f5604013d371b0a826a)
- Template system now supports default values [`67a9a9e`](https://github.com/RhetTbull/osxphotos/commit/67a9a9e21bd05d01a3202b0a1279487f5d04c9d9)
- Replaced template renderer with regex-based renderer [`427c4c0`](https://github.com/RhetTbull/osxphotos/commit/427c4c0bc49f671477866d30eee74834c67d7bc5)
#### [v0.23.4](https://github.com/RhetTbull/osxphotos/compare/v0.23.3...v0.23.4)
> 22 March 2020
- Added export_by_album.py to examples [`908fead`](https://github.com/RhetTbull/osxphotos/commit/908fead8a2fbcef3b4a387f34d83d88c507c5939)
- Updated CHANGELOG.md [`072e894`](https://github.com/RhetTbull/osxphotos/commit/072e894e56c4dfe5522d073b202933fed0204ef5)
- Updated pathvalidate calls [`d066435`](https://github.com/RhetTbull/osxphotos/commit/d066435e3df4062be6a0a3d5fa7308f293e764d5)
#### [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/*

1217
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,86 @@
""" 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,
)
@click.option(
"--edited",
help="Also export edited versions of photos (default is originals only)",
is_flag=True,
default=False,
)
def export(export_path, default_album, library_path, edited):
""" Export all photos, organized by album """
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.original_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)
filename = p.original_filename
# export the photo but only if --edited, photo has adjustments, and
# path_edited is not None (can be None if edited photo is missing)
if edited and p.hasadjustments and p.path_edited:
# export edited version
# use original filename with _edited appended but make sure suffix is
# same as edited file
edited_filename = f"{pathlib.Path(filename).stem}_edited{pathlib.Path(p.path_edited).suffix}"
exported = p.export(dest_dir, edited_filename, edited=True)
click.echo(f"Exported {edited_filename} to {exported}")
# export unedited version
exported = p.export(dest_dir, filename)
click.echo(f"Exported {filename} to {exported}")
else:
click.echo(f"Skipping missing photo: {p.original_filename} in album {album}")
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)

1721
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

53
osxphotos/_constants.py Normal file
View File

@@ -0,0 +1,53 @@
"""
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 5.0 and later have a different database structure
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.4
# 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_"
# photos with no reverse geolocation info (place)
_UNKNOWN_PLACE = "_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"
# Constants used for processing folders and albums
_PHOTOS_5_ALBUM_KIND = 2 # normal user album
_PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album
_PHOTOS_5_FOLDER_KIND = 4000 # user folder
_PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"

3
osxphotos/_version.py Normal file
View File

@@ -0,0 +1,3 @@
""" version info """
__version__ = "0.28.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'."""

211
osxphotos/albuminfo.py Normal file
View File

@@ -0,0 +1,211 @@
"""
AlbumInfo and FolderInfo classes for dealing with albums and folders
AlbumInfo class
Represents a single Album in the Photos library and provides access to the album's attributes
PhotosDB.albums() returns a list of AlbumInfo objects
FolderInfo class
Represents a single Folder in the Photos library and provides access to the folders attributes
PhotosDB.folders() returns a list of FolderInfo objects
"""
import logging
from ._constants import (
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_TOP_LEVEL_ALBUM,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_FOLDER_KIND,
)
class AlbumInfo:
"""
Info about a specific Album, contains all the details about the album
including folders, photos, etc.
"""
def __init__(self, db=None, uuid=None):
self._uuid = uuid
self._db = db
self._title = self._db._dbalbum_details[uuid]["title"]
@property
def title(self):
""" return title / name of album """
return self._title
@property
def uuid(self):
""" return uuid of album """
return self._uuid
@property
def photos(self):
""" return list of photos contained in album """
try:
return self._photos
except AttributeError:
uuid = self._db._dbalbums_album[self._uuid]
self._photos = self._db.photos(uuid=uuid)
return self._photos
@property
def folder_names(self):
""" return hierarchical list of folders the album is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders """
try:
return self._folder_names
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
else:
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
return self._folder_names
@property
def folder_list(self):
""" return hierarchical list of folders the album is contained in
as list of FolderInfo objects in form
["Top level folder", "sub folder 1", "sub folder 2", ...]
returns empty list if album is not in any folders """
try:
return self._folders
except AttributeError:
self._folders = self._db._album_folder_hierarchy_folderinfo(self._uuid)
return self._folders
@property
def parent(self):
""" returns FolderInfo object for parent folder or None if no parent (e.g. top-level album) """
try:
return self._parent
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
parent_uuid = self._db._dbalbum_details[self._uuid]["folderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
else None
)
else:
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
self._parent = (
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
if parent_pk != self._db._folder_root_pk
else None
)
return self._parent
def __len__(self):
""" return number of photos contained in album """
return len(self.photos)
class FolderInfo:
"""
Info about a specific folder, contains all the details about the folder
including folders, albums, etc
"""
def __init__(self, db=None, uuid=None):
self._uuid = uuid
self._db = db
if self._db._db_version <= _PHOTOS_4_VERSION:
self._pk = None
self._title = self._db._dbfolder_details[uuid]["name"]
else:
self._pk = self._db._dbalbum_details[uuid]["pk"]
self._title = self._db._dbalbum_details[uuid]["title"]
@property
def title(self):
""" return title / name of folder"""
return self._title
@property
def uuid(self):
""" return uuid of folder """
return self._uuid
@property
def album_info(self):
""" return list of albums (as AlbumInfo objects) contained in the folder """
try:
return self._albums
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
albums = [
AlbumInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["albumSubclass"] == _PHOTOS_4_ALBUM_KIND
and detail["folderUuid"] == self._uuid
]
else:
albums = [
AlbumInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["kind"] == _PHOTOS_5_ALBUM_KIND
and detail["parentfolder"] == self._pk
]
self._albums = albums
return self._albums
@property
def parent(self):
""" returns FolderInfo object for parent or None if no parent (e.g. top-level folder) """
try:
return self._parent
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
parent_uuid = self._db._dbfolder_details[self._uuid]["parentFolderUuid"]
self._parent = (
FolderInfo(db=self._db, uuid=parent_uuid)
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
else None
)
else:
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
self._parent = (
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
if parent_pk != self._db._folder_root_pk
else None
)
return self._parent
@property
def subfolders(self):
""" return list of folders (as FolderInfo objects) contained in the folder """
try:
return self._folders
except AttributeError:
if self._db._db_version <= _PHOTOS_4_VERSION:
folders = [
FolderInfo(db=self._db, uuid=folder)
for folder, detail in self._db._dbfolder_details.items()
if not detail["intrash"]
and not detail["isMagic"]
and detail["parentFolderUuid"] == self._uuid
]
else:
folders = [
FolderInfo(db=self._db, uuid=album)
for album, detail in self._db._dbalbum_details.items()
if not detail["intrash"]
and detail["kind"] == _PHOTOS_5_FOLDER_KIND
and detail["parentfolder"] == self._pk
]
self._folders = folders
return self._folders
def __len__(self):
""" returns count of folders + albums contained in the folder """
return len(self.subfolders) + len(self.album_info)

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

1123
osxphotos/photoinfo.py Normal file

File diff suppressed because it is too large Load Diff

2158
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

439
osxphotos/template.py Normal file
View File

@@ -0,0 +1,439 @@
""" Custom template system for osxphotos """
# Rolled my own template system because:
# 1. Needed to handle multiple values (e.g. album, keyword)
# 2. Needed to handle default values if template not found
# 3. Didn't want user to need to know python (e.g. by using Mako which is
# already used elsewhere in this project)
# 4. Couldn't figure out how to do #1 and #2 with str.format()
#
# This code isn't elegant but it seems to work well. PRs gladly accepted.
import datetime
import os
import pathlib
import re
from typing import Tuple, List # pylint: disable=syntax-error
from .photoinfo import PhotoInfo
from ._constants import _UNKNOWN_PERSON
# Permitted substitutions (each of these returns a single value or None)
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 geolocation 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'",
}
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
"{album}": "Album(s) photo is contained in",
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
"{keyword}": "Keyword(s) assigned to photo",
"{person}": "Person(s) / face(s) in a photo",
}
# Just the multi-valued substitution names without the braces
MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys()
]
def get_template_value(lookup, photo):
""" lookup template value (single-value template substitutions) for use in make_subst_function
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, photo, none_str="_"):
""" render a filename or directory template
template: str template
photo: PhotoInfo object
none_str: str to use default for None values, default is '_' """
# the rendering happens in two phases:
# phase 1: handle all the single-value template substitutions
# results in a single string with all the template fields replaced
# phase 2: loop through all the multi-value template substitutions
# could result in multiple strings
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
# there would be 6 possible renderings (2 albums x 3 persons)
# regex to find {template_field,optional_default} in strings
# for explanation of regex see https://regex101.com/r/4JJg42/1
# pylint: disable=anomalous-backslash-in-string
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)(?=\}(?!\}))\}"
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, get_func=get_template_value):
""" returns: substitution function for use in re.sub
photo: a PhotoInfo object
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
# closure to capture photo, none_str in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 4:
try:
val = get_func(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)
# do multi-valued placements
# start with the single string from phase 1 above then loop through all
# multi-valued fields and all values for each of those fields
# rendered_strings will be updated as each field is processed
# for example: if two albums, two keywords, and one person and template is:
# "{created.year}/{album}/{keyword}/{person}"
# rendered strings would do the following:
# start (created.year filled in phase 1)
# ['2011/{album}/{keyword}/{person}']
# after processing albums:
# ['2011/Album1/{keyword}/{person}',
# '2011/Album2/{keyword}/{person}',]
# after processing keywords:
# ['2011/Album1/keyword1/{person}',
# '2011/Album1/keyword2/{person}',
# '2011/Album2/keyword1/{person}',
# '2011/Album2/keyword2/{person}',]
# after processing person:
# ['2011/Album1/keyword1/person1',
# '2011/Album1/keyword2/person1',
# '2011/Album2/keyword1/person1',
# '2011/Album2/keyword2/person1',]
rendered_strings = set([rendered])
for field in MULTI_VALUE_SUBSTITUTIONS:
if field == "album":
values = photo.albums
elif field == "keyword":
values = photo.keywords
elif field == "person":
values = photo.persons
# remove any _UNKNOWN_PERSON values
values = [val for val in values if val != _UNKNOWN_PERSON]
elif field == "folder_album":
values = []
# photos must be in an album to be in a folder
for album in photo.album_info:
if album.folder_names:
# album in folder
folder = os.path.sep.join(album.folder_names)
folder += os.path.sep + album.title
values.append(folder)
else:
# album not in folder
values.append(album.title)
else:
raise ValueError(f"Unhandleded template value: {field}")
# If no values, insert None so code below will substite none_str for None
values = values or [None]
# Build a regex that matches only the field being processed
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
regex_multi = re.compile(re_str)
# holds each of the new rendered_strings, set() to avoid duplicates
new_strings = set()
for str_template in rendered_strings:
for val in values:
def get_template_value_multi(lookup_value, photo):
""" Closure passed to make_subst_function get_func
Capture val and field in the closure
Allows make_subst_function to be re-used w/o modification """
if lookup_value == field:
return val
else:
raise KeyError(f"Unexpected value: {lookup_value}")
subst = make_subst_function(
photo, none_str, get_func=get_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings.add(new_string)
# update rendered_strings for the next field to process
rendered_strings = new_strings
# find any {fields} that weren't replaced
unmatched = []
for rendered_str in rendered_strings:
unmatched.extend(
[
no_match[0]
for no_match in re.findall(regex, rendered_str)
if no_match[0] not in unmatched
]
)
# fix any escaped curly braces
rendered_strings = [
rendered_str.replace("{{", "{").replace("}}", "}")
for rendered_str in rendered_strings
]
return rendered_strings, 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>

494
osxphotos/utils.py Normal file
View File

@@ -0,0 +1,494 @@
import fnmatch
import glob
import logging
import os
import os.path
import pathlib
import platform
import re
import sqlite3
import subprocess
import sys
import tempfile
import urllib.parse
from plistlib import load as plistload
import CoreFoundation
import CoreServices
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, norsrc=False):
""" Copies a file from src path to dest path
src: source path as string
dest: destination path as string
norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy
resource fork or extended attributes. May be useful on volumes that
don't work with extended attributes (likely only certain SMB mounts)
default is False
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 FileNotFoundError("src file does not appear to exist", src)
if norsrc:
command = ["/usr/bin/ditto", "--norsrc", src, dest]
else:
command = ["/usr/bin/ditto", src, dest]
# if error on copy, subprocess will raise CalledProcessError
try:
result = subprocess.run(command, 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
return result.returncode
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
def get_preferred_uti_extension(uti):
""" get preferred extension for a UTI type
uti: UTI str, e.g. 'public.jpeg'
returns: preferred extension as str """
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
ext = CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
return ext
def findfiles(pattern, path_):
"""Returns list of filenames from path_ matched by pattern
shell pattern. Matching is case-insensitive."""
# See: https://gist.github.com/techtonik/5694830
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
return [name for name in os.listdir(path_) if rule.match(name)]
# 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.2",
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/)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2019-08-24T02:50:48Z</date>
<date>2020-04-17T18:39:50Z</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-04-17T18:40:46Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2019-08-24T13:19:30Z</date>
<date>2020-04-17T18:39:51Z</date>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2019-08-24T02:51:30Z</date>
<date>2020-04-17T18:39:52Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>414</integer>
<integer>502</integer>
<key>LibraryBuildTag</key>
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
<key>LibrarySchemaVersion</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -9,7 +9,7 @@
<key>HistoricalMarker</key>
<dict>
<key>LastHistoryRowId</key>
<integer>403</integer>
<integer>502</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>

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