Compare commits

...

76 Commits

Author SHA1 Message Date
Rhet Turnbull
bb4bc8fd96 Added --description-template to CLI, closes #166 2020-06-28 20:10:38 -07:00
Rhet Turnbull
59507077ba Updated README.md 2020-06-28 13:50:12 -07:00
Rhet Turnbull
ff0328785f Added expand_inplace to PhotoTemplate.render 2020-06-28 13:46:35 -07:00
Rhet Turnbull
3693d65b82 Added --deleted, --deleted-only to CLI, closes #179 2020-06-28 10:02:36 -07:00
Rhet Turnbull
6a85bd215a Updated CHANGELOG.md 2020-06-27 19:21:27 -07:00
Rhet Turnbull
ab36264af0 Changed default to PhotosDB.photos(movies=True), closes #177 2020-06-27 12:57:46 -07:00
Rhet Turnbull
185483e1aa added intrash support for issue #179 2020-06-27 10:54:25 -07:00
Rhet Turnbull
c1d12047bd Removed pdf filter on process_database_4 2020-06-26 20:04:53 -07:00
Rhet Turnbull
46c87eeed5 Added test for issue #178 2020-06-23 22:12:32 -07:00
Rhet Turnbull
fd4c99032d Additional fix for issue #178 2020-06-23 12:21:46 -07:00
Rhet Turnbull
d6fee89fd9 version bump 2020-06-23 12:07:07 -07:00
Rhet Turnbull
b8618cf272 Bug fix for issue #178 2020-06-23 12:01:20 -07:00
Rhet Turnbull
6b7c5d07fd Updated CHANGELOG.md 2020-06-22 07:22:19 -07:00
Rhet Turnbull
bd5ba702aa Closes #174 2020-06-22 07:14:10 -07:00
Rhet Turnbull
c8d76a89e4 Added today to template system, closes #167 2020-06-21 21:58:18 -07:00
Rhet Turnbull
a8e996e660 Minor refactoring in photoinfo.py 2020-06-21 12:06:25 -07:00
Rhet Turnbull
c68a5ab39f Updated CHANGELOG.md 2020-06-21 09:01:15 -07:00
Rhet Turnbull
1ebf995833 Bug fix for issue #172 2020-06-21 08:42:19 -07:00
Rhet Turnbull
538bac7ade More PhotoInfo.albums refactoring, closes #169 2020-06-21 08:18:11 -07:00
Rhet Turnbull
32806c8459 Updated CHANGELOG.md 2020-06-20 17:44:18 -07:00
Rhet Turnbull
cfabd0dbea Refactored album code in photosdb to fix issue #169 2020-06-20 17:31:33 -07:00
Rhet Turnbull
a23259948c Updated CHANGELOG.md 2020-06-20 08:43:42 -07:00
Rhet Turnbull
1212fad4ad Fixed PhotoInfo.albums, album_info for issue #169 2020-06-20 08:36:03 -07:00
Rhet Turnbull
567abe3311 Updated CHANGELOG.md 2020-06-18 22:52:21 -07:00
Rhet Turnbull
5a832181f7 Fixed get_last_library_path and get_system_library_path to not raise KeyError 2020-06-18 22:16:11 -07:00
Rhet Turnbull
4da57a1cee Merge pull request #168 from dethi/thibault/fix-exception-when-SystemLibraryPath-is-not-present
Don't raise KeyError when SystemLibraryPath is absent
2020-06-18 21:38:53 -07:00
Thibault Deutsch
1fd0f96b14 Don't raise KeyError when SystemLibraryPath is absent 2020-06-18 23:43:55 +01:00
Rhet Turnbull
e98c3fe429 Added show() to photos_repl.py 2020-06-16 22:46:46 -07:00
Rhet Turnbull
d77e9747cd Added check for export db in directory branch, closes #164 2020-06-14 17:51:57 -07:00
Rhet Turnbull
43d28e78f3 Added OSXPhotosDB.get_db_connection() 2020-06-14 12:52:23 -07:00
Rhet Turnbull
00bc50490e Updated CHANGELOG.md 2020-06-14 08:44:55 -07:00
Rhet Turnbull
f8743c33bd Updated CHANGELOG.md 2020-06-14 08:41:36 -07:00
Rhet Turnbull
937da9e617 Added computed aesthetic scores, closes #141, closes #122 2020-06-14 08:09:37 -07:00
Rhet Turnbull
435868a0a7 Updated CHANGELOG.md 2020-06-13 19:46:55 -07:00
Rhet Turnbull
d9802247d9 Added --label to CLI, closes #157 2020-06-13 19:40:46 -07:00
Rhet Turnbull
f39a92a352 Updated CHANGELOG.md 2020-06-13 15:11:10 -07:00
Rhet Turnbull
40dc7d32f2 Extende --ignore-case to --person, --keyword, --album, closes #162 2020-06-13 15:06:27 -07:00
Rhet Turnbull
4cd6c8f617 Updated CHANGELOG.md 2020-06-13 11:32:48 -07:00
Rhet Turnbull
0004250e74 Updated README.md to document template system 2020-06-13 10:52:18 -07:00
Rhet Turnbull
868ee7737b Added hour, min, sec, strftime templates, closes #158 2020-06-13 10:32:04 -07:00
Rhet Turnbull
5387f8e2f9 Added hour, min, sec to template system, issue #158 2020-06-13 09:17:34 -07:00
Rhet Turnbull
73b499f405 Updated CHANGELOG.md 2020-06-13 09:04:23 -07:00
Rhet Turnbull
06fa1edcae Bug fix for issue #136 2020-06-13 08:52:23 -07:00
Rhet Turnbull
cf2615da62 Updated DatetimeFormatter to include hour/min/sec 2020-06-13 08:32:56 -07:00
Rhet Turnbull
4ba1982d74 Added test for issue #156 2020-06-09 23:03:30 -07:00
Rhet Turnbull
abd10b73e8 Updated help text for debug-dump 2020-06-07 14:32:49 -07:00
Rhet Turnbull
7cd7b51598 Added hidden debug-dump command to CLI 2020-06-07 14:17:08 -07:00
Rhet Turnbull
801dc62c4b Updated CHANGELOG.md 2020-06-07 08:29:48 -07:00
Rhet Turnbull
72f034ef85 Fix for bug in handling of deleted albums to address issue #156 2020-06-07 08:14:02 -07:00
Rhet Turnbull
cb993f2e5e Merge branch 'master' of https://github.com/RhetTbull/osxphotos 2020-06-06 12:12:05 -07:00
Rhet Turnbull
2271d89355 Partial fix for #155 2020-06-06 12:11:50 -07:00
Rhet Turnbull
62d096b5a1 Partial fix for #155 2020-06-06 12:09:51 -07:00
Rhet Turnbull
5c7a0c3a24 Refactoring with sourceryAI 2020-06-01 21:06:09 -07:00
Rhet Turnbull
ec727cc556 Updated CHANGELOG.md 2020-05-31 11:31:24 -07:00
Rhet Turnbull
6c84827ec7 Added --filename to CLI, closes #89 2020-05-31 11:25:34 -07:00
Rhet Turnbull
d47fd46a21 Updated CHANGELOG.md 2020-05-31 06:32:06 -07:00
Rhet Turnbull
7707bc1625 Added --edited-suffix to CLI, closes #145 2020-05-30 18:33:52 -07:00
Rhet Turnbull
fc1bf08cfa Removed pyinstaller 2020-05-30 14:43:29 -07:00
Rhet Turnbull
f35ea70b72 More refactoring in PhotoTemplate 2020-05-30 14:42:08 -07:00
Rhet Turnbull
16f802bf71 Refactored template code out of PhotoInfo into PhotoTemplate 2020-05-30 13:14:07 -07:00
Rhet Turnbull
8d2d5a8a33 refactored render_template, closes #149 2020-05-30 11:27:44 -07:00
Rhet Turnbull
3a8bef1572 Added test for SearchInfo on 10.15.5 2020-05-29 21:40:29 -07:00
Rhet Turnbull
fd71b13c77 performance improvements for SearchInfo 2020-05-29 20:30:20 -07:00
Rhet Turnbull
2243395bff Added test for Photos 5 on 10.15.5 2020-05-28 21:10:45 -07:00
Rhet Turnbull
42b89d34f3 performance improvements for update and export_db 2020-05-27 20:52:05 -07:00
Rhet Turnbull
9b11cbf32b Fixed searchinfo so processing can continue if no search DB found 2020-05-25 17:50:28 -07:00
Rhet Turnbull
8c3bf040c6 Update test library 2020-05-25 17:47:25 -07:00
Rhet Turnbull
c0855c7702 Updated CHANGELOG.md 2020-05-25 10:54:48 -07:00
Rhet Turnbull
288b1abc1c Updated CHANGELOG.md 2020-05-25 10:50:54 -07:00
Rhet Turnbull
9eae66030e Added --dry-run option to CLI export, closes #91 2020-05-25 10:37:30 -07:00
Rhet Turnbull
46fdc94398 Catch exception in folder processing to address #148 2020-05-24 07:22:00 -07:00
Rhet Turnbull
09c7d18901 Added test for DateTimeFormatter.dow 2020-05-24 06:37:15 -07:00
Rhet Turnbull
5f071b9c3f Merge pull request #147 from grundsch/add_dow
added created.dow (day of week) to template
2020-05-24 06:17:40 -07:00
Stephane
8df6d2c707 added created.dow (day of week) to template 2020-05-24 14:20:25 +02:00
Rhet Turnbull
28935b0af9 added created.dd and modified.dd to template system, closes #135 2020-05-23 21:39:40 -07:00
Rhet Turnbull
af750dd2e3 Updated CHANGELOG.md 2020-05-23 17:47:47 -07:00
270 changed files with 6967 additions and 1534 deletions

View File

@@ -4,6 +4,166 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.30.1](https://github.com/RhetTbull/osxphotos/compare/v0.30.0...v0.30.1)
> 27 June 2020
- Changed default to PhotosDB.photos(movies=True), closes #177 [`#177`](https://github.com/RhetTbull/osxphotos/issues/177)
#### [v0.30.0](https://github.com/RhetTbull/osxphotos/compare/v0.29.30...v0.30.0)
> 27 June 2020
- added intrash support for issue #179 [`185483e`](https://github.com/RhetTbull/osxphotos/commit/185483e1aa9ed107402bfb178f264417e6926b46)
- Removed pdf filter on process_database_4 [`c1d1204`](https://github.com/RhetTbull/osxphotos/commit/c1d12047bde84740b96c8531110e7b2d2fe41f2e)
#### [v0.29.30](https://github.com/RhetTbull/osxphotos/compare/v0.29.29...v0.29.30)
> 24 June 2020
- Added test for issue #178 [`46c87ee`](https://github.com/RhetTbull/osxphotos/commit/46c87eeed56d5765317dec4992d2e16323c711ad)
- Additional fix for issue #178 [`fd4c990`](https://github.com/RhetTbull/osxphotos/commit/fd4c99032dbbedd6325aabacb0bc800b24ede413)
#### [v0.29.29](https://github.com/RhetTbull/osxphotos/compare/v0.29.28...v0.29.29)
> 23 June 2020
- version bump [`d6fee89`](https://github.com/RhetTbull/osxphotos/commit/d6fee89fd9dd07c4788562ed551d0a3f2b5d697d)
- Bug fix for issue #178 [`b8618cf`](https://github.com/RhetTbull/osxphotos/commit/b8618cf272efc174b7fa872f233b561bd9e7243e)
#### [v0.29.28](https://github.com/RhetTbull/osxphotos/compare/v0.29.26...v0.29.28)
> 22 June 2020
- Closes #174 [`#174`](https://github.com/RhetTbull/osxphotos/issues/174)
- Added today to template system, closes #167 [`#167`](https://github.com/RhetTbull/osxphotos/issues/167)
- Minor refactoring in photoinfo.py [`a8e996e`](https://github.com/RhetTbull/osxphotos/commit/a8e996e66072e94de93fd4ea78a456bc61831f52)
#### [v0.29.26](https://github.com/RhetTbull/osxphotos/compare/v0.29.25...v0.29.26)
> 21 June 2020
- Bug fix for issue #172 [`1ebf995`](https://github.com/RhetTbull/osxphotos/commit/1ebf99583397617f0d3a234c898beae1c14f5a63)
#### [v0.29.25](https://github.com/RhetTbull/osxphotos/compare/v0.29.24...v0.29.25)
> 21 June 2020
- More PhotoInfo.albums refactoring, closes #169 [`#169`](https://github.com/RhetTbull/osxphotos/issues/169)
#### [v0.29.24](https://github.com/RhetTbull/osxphotos/compare/v0.29.23...v0.29.24)
> 21 June 2020
- Refactored album code in photosdb to fix issue #169 [`cfabd0d`](https://github.com/RhetTbull/osxphotos/commit/cfabd0dbead62c8ab6a774899239e5da5bfe1203)
#### [v0.29.23](https://github.com/RhetTbull/osxphotos/compare/v0.29.22...v0.29.23)
> 20 June 2020
- Fixed PhotoInfo.albums, album_info for issue #169 [`1212fad`](https://github.com/RhetTbull/osxphotos/commit/1212fad4adde0b4c6b2887392eed829d8d96d61d)
#### [v0.29.22](https://github.com/RhetTbull/osxphotos/compare/v0.29.19...v0.29.22)
> 19 June 2020
- Don't raise KeyError when SystemLibraryPath is absent [`#168`](https://github.com/RhetTbull/osxphotos/pull/168)
- Added check for export db in directory branch, closes #164 [`#164`](https://github.com/RhetTbull/osxphotos/issues/164)
- Added OSXPhotosDB.get_db_connection() [`43d28e7`](https://github.com/RhetTbull/osxphotos/commit/43d28e78f394fa33f8d88f64b56b7dc7258cd454)
- Added show() to photos_repl.py [`e98c3fe`](https://github.com/RhetTbull/osxphotos/commit/e98c3fe42912ac16d13675bf14154981089d41ea)
- Fixed get_last_library_path and get_system_library_path to not raise KeyError [`5a83218`](https://github.com/RhetTbull/osxphotos/commit/5a832181f73e082927c80864f2063e554906b06b)
- Don't raise KeyError when SystemLibraryPath is absent [`1fd0f96`](https://github.com/RhetTbull/osxphotos/commit/1fd0f96b14f0bc38e47bddb4cae12e19406324fb)
#### [v0.29.19](https://github.com/RhetTbull/osxphotos/compare/v0.29.18...v0.29.19)
> 14 June 2020
- Added computed aesthetic scores, closes #141, closes #122 [`#141`](https://github.com/RhetTbull/osxphotos/issues/141) [`#122`](https://github.com/RhetTbull/osxphotos/issues/122)
#### [v0.29.18](https://github.com/RhetTbull/osxphotos/compare/v0.29.17...v0.29.18)
> 14 June 2020
- Added --label to CLI, closes #157 [`#157`](https://github.com/RhetTbull/osxphotos/issues/157)
#### [v0.29.17](https://github.com/RhetTbull/osxphotos/compare/v0.29.16...v0.29.17)
> 13 June 2020
- Extende --ignore-case to --person, --keyword, --album, closes #162 [`#162`](https://github.com/RhetTbull/osxphotos/issues/162)
- Updated README.md to document template system [`0004250`](https://github.com/RhetTbull/osxphotos/commit/0004250e74eacc19f7986742712225116530a67e)
#### [v0.29.16](https://github.com/RhetTbull/osxphotos/compare/v0.29.14...v0.29.16)
> 13 June 2020
- Added hour, min, sec, strftime templates, closes #158 [`#158`](https://github.com/RhetTbull/osxphotos/issues/158)
- Added hour, min, sec to template system, issue #158 [`5387f8e`](https://github.com/RhetTbull/osxphotos/commit/5387f8e2f970ff7fa1967ccad87b45a4f7e50d32)
#### [v0.29.14](https://github.com/RhetTbull/osxphotos/compare/v0.29.13...v0.29.14)
> 13 June 2020
- Updated DatetimeFormatter to include hour/min/sec [`cf2615d`](https://github.com/RhetTbull/osxphotos/commit/cf2615da62801f1fbde61c7905431963e121e2e9)
- Added test for issue #156 [`4ba1982`](https://github.com/RhetTbull/osxphotos/commit/4ba1982d745f0d532ead090177051d928465ed03)
- Bug fix for issue #136 [`06fa1ed`](https://github.com/RhetTbull/osxphotos/commit/06fa1edcae7139b543e17ec63810c37c18cc2780)
#### [v0.29.13](https://github.com/RhetTbull/osxphotos/compare/v0.29.12...v0.29.13)
> 7 June 2020
- Added hidden debug-dump command to CLI [`7cd7b51`](https://github.com/RhetTbull/osxphotos/commit/7cd7b5159845fce15d50a7bfc0ac50d122bee527)
#### [v0.29.12](https://github.com/RhetTbull/osxphotos/compare/v0.29.9...v0.29.12)
> 7 June 2020
- Fix for bug in handling of deleted albums to address issue #156 [`72f034e`](https://github.com/RhetTbull/osxphotos/commit/72f034ef85010544a158d8301b898b5d0d865b05)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`cb993f2`](https://github.com/RhetTbull/osxphotos/commit/cb993f2e5e2df7e0a15b3b2fdb92b65a8de56974)
- Refactoring with sourceryAI [`5c7a0c3`](https://github.com/RhetTbull/osxphotos/commit/5c7a0c3a246cd5fec329b4fd4979d2b77352f916)
#### [v0.29.9](https://github.com/RhetTbull/osxphotos/compare/v0.29.8...v0.29.9)
> 31 May 2020
- Added --filename to CLI, closes #89 [`#89`](https://github.com/RhetTbull/osxphotos/issues/89)
#### [v0.29.8](https://github.com/RhetTbull/osxphotos/compare/v0.29.5...v0.29.8)
> 31 May 2020
- Added --edited-suffix to CLI, closes #145 [`#145`](https://github.com/RhetTbull/osxphotos/issues/145)
- refactored render_template, closes #149 [`#149`](https://github.com/RhetTbull/osxphotos/issues/149)
- Added test for Photos 5 on 10.15.5 [`2243395`](https://github.com/RhetTbull/osxphotos/commit/2243395bff9e1cc379626cc5007e44e6e63b95e0)
- Refactored template code out of PhotoInfo into PhotoTemplate [`16f802b`](https://github.com/RhetTbull/osxphotos/commit/16f802bf717610e13712b8aa477d05d94b14d294)
- Added test for SearchInfo on 10.15.5 [`3a8bef1`](https://github.com/RhetTbull/osxphotos/commit/3a8bef1572e4d83b1e0a4b85c8f06e329cc7e8de)
- performance improvements for update and export_db [`42b89d3`](https://github.com/RhetTbull/osxphotos/commit/42b89d34f3d14818daefbd3bfabc1be9344d2e1a)
- More refactoring in PhotoTemplate [`f35ea70`](https://github.com/RhetTbull/osxphotos/commit/f35ea70b72e8c6743b1f6009466d2a15d40338ac)
#### [v0.29.5](https://github.com/RhetTbull/osxphotos/compare/v0.29.2...v0.29.5)
> 25 May 2020
- added created.dow (day of week) to template [`#147`](https://github.com/RhetTbull/osxphotos/pull/147)
- Added --dry-run option to CLI export, closes #91 [`#91`](https://github.com/RhetTbull/osxphotos/issues/91)
- added created.dd and modified.dd to template system, closes #135 [`#135`](https://github.com/RhetTbull/osxphotos/issues/135)
- Catch exception in folder processing to address #148 [`46fdc94`](https://github.com/RhetTbull/osxphotos/commit/46fdc94398c80b157048649434c7312074ce5c58)
- added created.dow (day of week) to template [`8df6d2c`](https://github.com/RhetTbull/osxphotos/commit/8df6d2c707caf4eb35696888282365a128b69569)
- Added test for DateTimeFormatter.dow [`09c7d18`](https://github.com/RhetTbull/osxphotos/commit/09c7d18901b61669d8b9242babd82eba6987c89a)
#### [v0.29.2](https://github.com/RhetTbull/osxphotos/compare/v0.29.1...v0.29.2)
> 24 May 2020
- Added try/except for bad datettime values [`1d095d7`](https://github.com/RhetTbull/osxphotos/commit/1d095d7284bae57037b8b200c8b3422835c611b2)
#### [v0.29.1](https://github.com/RhetTbull/osxphotos/compare/v0.29.0...v0.29.1)
> 23 May 2020
- Catch illegal timestamp value [`#146`](https://github.com/RhetTbull/osxphotos/pull/146)
- Catch illegal timestamp value [`441de71`](https://github.com/RhetTbull/osxphotos/commit/441de711dc664b244d599c81e3dd1bcd9b2e55a0)
#### [v0.29.0](https://github.com/RhetTbull/osxphotos/compare/v0.28.19...v0.29.0)
> 23 May 2020
@@ -12,8 +172,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added --update to CLI export; reference issue #100 [`b1171e9`](https://github.com/RhetTbull/osxphotos/commit/b1171e96cc06362555725995bb311317eb163e49)
- Added as_dict to PlaceInfo [`8c4fe40`](https://github.com/RhetTbull/osxphotos/commit/8c4fe40aa6850f166e526cffaa088550884399af)
- Updated README.md [`11d368a`](https://github.com/RhetTbull/osxphotos/commit/11d368a69cbe67e909e64b020f0334fc09dd3ac4)
- Updated CHANGELOG.md [`cafa483`](https://github.com/RhetTbull/osxphotos/commit/cafa483cfc228c651a03d3361d6d48a35deab1e8)
- version bump [`c06c230`](https://github.com/RhetTbull/osxphotos/commit/c06c230a469754691d11fff1034fb02daeeba649)
- Test library update [`f416418`](https://github.com/RhetTbull/osxphotos/commit/f416418546a12bc6c1bda13f6b712758584d06dc)
#### [v0.28.19](https://github.com/RhetTbull/osxphotos/compare/v0.28.18...v0.28.19)
@@ -22,16 +182,15 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added label and label_normalized to template system, closes #130 [`#130`](https://github.com/RhetTbull/osxphotos/issues/130)
- Revert "test library updates" [`48e9c32`](https://github.com/RhetTbull/osxphotos/commit/48e9c32add549e66c3ef8c65f8821f5033b55b11)
- test library updates [`d125854`](https://github.com/RhetTbull/osxphotos/commit/d125854f2a04e37747af3e0796370a565c1c9bd0)
- Updated CHANGELOG.md [`e228cfa`](https://github.com/RhetTbull/osxphotos/commit/e228cfab746055c8d6df428aebe0ed001fb6d4d0)
- version bump [`bd9d5a2`](https://github.com/RhetTbull/osxphotos/commit/bd9d5a26f3bfcbb33896a139fa86cdab46768103)
- Update README.md [`85760dc`](https://github.com/RhetTbull/osxphotos/commit/85760dc4fe2274d826ed80494fd4e66866398609)
- Update README.md [`be07f90`](https://github.com/RhetTbull/osxphotos/commit/be07f90e5a8179e452730ea654e4c9627b1f6ebc)
#### [v0.28.18](https://github.com/RhetTbull/osxphotos/compare/v0.28.17...v0.28.18)
> 14 May 2020
- Implemented PhotoInfo.exiftool [`a80dee4`](https://github.com/RhetTbull/osxphotos/commit/a80dee401c7eb959f6ad6d93a3272657ed28f521)
- Updated CHANGELOG.md [`e67fce2`](https://github.com/RhetTbull/osxphotos/commit/e67fce28714cf4065b64202bb3b149ba5bec5be4)
#### [v0.28.17](https://github.com/RhetTbull/osxphotos/compare/v0.28.15...v0.28.17)
@@ -48,8 +207,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- added --export-as-hardlink option [`#126`](https://github.com/RhetTbull/osxphotos/pull/126)
- Added test for folder_names on 10.15.4, closes #119 [`#119`](https://github.com/RhetTbull/osxphotos/issues/119)
- Refactored photosdb and photoinfo to add SearchInfo and labels [`98b3f63`](https://github.com/RhetTbull/osxphotos/commit/98b3f63a92aa2105f8fa97af992fc6fe2d78b973)
- added --export-as-hardlink option [`5eb0876`](https://github.com/RhetTbull/osxphotos/commit/5eb0876e331beb020431bb037dee75fb7ae61c85)
- Added additional test for --export-as-hardlink [`57315d4`](https://github.com/RhetTbull/osxphotos/commit/57315d44497fde977956f76f667470208f11aa2d)
- added CHANGELOG.md [`00e1661`](https://github.com/RhetTbull/osxphotos/commit/00e16611fc86c05fb090d036084db9eb42444071)
- Updated a couple of tests to use pytest-mock [`397db0d`](https://github.com/RhetTbull/osxphotos/commit/397db0d72fb218669a9ecbff134fa9b392a14661)
- added test for export using hardlinks, fixed a test that failed if users locale settings were different to en_US [`b0ec6c6`](https://github.com/RhetTbull/osxphotos/commit/b0ec6c6b36d8cfe05723d47b210d9d7c5aabdfe5)
@@ -73,7 +232,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 28 April 2020
- Fixed implementation of use_albums_as_keywords and use_persons_as_keywords, closes #115 [`#115`](https://github.com/RhetTbull/osxphotos/issues/115)
- Updated CHANGELOG.md [`072a8d7`](https://github.com/RhetTbull/osxphotos/commit/072a8d795e5e15fa8ca8d8872aecf4cddd7837f7)
- Update README.md [`5cc98c3`](https://github.com/RhetTbull/osxphotos/commit/5cc98c338bcc19fd05bf293eb3afe24c07c8b380)
- Updated README.md [`a800711`](https://github.com/RhetTbull/osxphotos/commit/a80071111f810a1d7d6e2d735839e85499091ea4)
- Update README.md [`1c9d4f2`](https://github.com/RhetTbull/osxphotos/commit/1c9d4f282beea2ac12273c8d0f9453bad1255c2c)
@@ -84,7 +242,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added --album-keyword and --person-keyword to CLI, closes #61 [`#61`](https://github.com/RhetTbull/osxphotos/issues/61)
- Updated test libraries [`54d5d4b`](https://github.com/RhetTbull/osxphotos/commit/54d5d4b7ba99204f58e723231309ab6e306be28c)
- Updated CHANGELOG.md [`38137a1`](https://github.com/RhetTbull/osxphotos/commit/38137a1351cdb7ab72393ea03828933dac0b76b0)
- Updated tests/README.md [`56a0006`](https://github.com/RhetTbull/osxphotos/commit/56a000609f2f08d0f8800fec49cada2980c3bb9d)
#### [v0.28.6](https://github.com/RhetTbull/osxphotos/compare/v0.28.5...v0.28.6)
@@ -92,7 +249,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 26 April 2020
- Fixed locale bug in templates, closes #113 [`#113`](https://github.com/RhetTbull/osxphotos/issues/113)
- Updated CHANGELOG.md [`81d4e39`](https://github.com/RhetTbull/osxphotos/commit/81d4e392c39f0fe6f967a447c7d0c970bf224032)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`4b29a2e`](https://github.com/RhetTbull/osxphotos/commit/4b29a2e05fd1dac821d80781ae01a148d3d9c523)
- Updated test to avoid issue with GitHub workflow [`9be0f84`](https://github.com/RhetTbull/osxphotos/commit/9be0f849b73061d053d30274ff3295b79c88f0b6)
- Update pythonpackage.yml to remove older pythons [`ccb5f25`](https://github.com/RhetTbull/osxphotos/commit/ccb5f252d14e9335ae04a2e338a6d527b80c9a93)
@@ -106,7 +263,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Updated setup.py to resolve issue with bpylist2 on python < 3.8 [`8e4b88a`](https://github.com/RhetTbull/osxphotos/commit/8e4b88ad1fc18438f941e045bfc8aeac878914f9)
- Added cli.py for use with pyinstaller [`cf28cb6`](https://github.com/RhetTbull/osxphotos/commit/cf28cb6452de17f2ef8d80435386e8d5a1aabd34)
- added raw_is_original handling [`a337e79`](https://github.com/RhetTbull/osxphotos/commit/a337e79e13802b4824c2f088ce9db1c027d6f3c5)
- Updated CHANGELOG.md [`22f1e8f`](https://github.com/RhetTbull/osxphotos/commit/22f1e8f2a6478e0576f6bff53e348aad8680ae69)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`1c8eb76`](https://github.com/RhetTbull/osxphotos/commit/1c8eb764f53c3cc8b541667c858e462793ad8d1f)
#### [0.28.2](https://github.com/RhetTbull/osxphotos/compare/v0.28.1...0.28.2)
@@ -114,7 +271,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- Added folder support for Photos <= 4, closes #93 [`#93`](https://github.com/RhetTbull/osxphotos/issues/93)
- cleaned up SQL statements in _process_database4 [`6f28171`](https://github.com/RhetTbull/osxphotos/commit/6f281711e2001a63ffad076d7b9835272d5d09da)
- Updated CHANGELOG.md [`1fa9583`](https://github.com/RhetTbull/osxphotos/commit/1fa9583ea689d54d2613a064f1ade25bcdfbf043)
- Fixed suffix check on export to be case insensitive [`4b30b3b`](https://github.com/RhetTbull/osxphotos/commit/4b30b3b4260e2c7409e18825e5b626efe646db16)
- test library update [`3bac106`](https://github.com/RhetTbull/osxphotos/commit/3bac106eb7a180e9e39643a89087d92bf2a437d0)
@@ -132,7 +288,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
@@ -146,7 +301,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 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)
@@ -155,8 +309,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Update README.md [`1aa3838`](https://github.com/RhetTbull/osxphotos/commit/1aa3838c3866a18084ffe822de02df0eda464d71)
#### [v0.26.1](https://github.com/RhetTbull/osxphotos/compare/v0.26.0...v0.26.1)
@@ -164,7 +318,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
@@ -172,7 +325,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Update README.md [`626e460`](https://github.com/RhetTbull/osxphotos/commit/626e460aabb97b30af87cea2ec4f93e5fb925bec)
#### [v0.25.1](https://github.com/RhetTbull/osxphotos/compare/v0.25.0...v0.25.1)
@@ -191,7 +344,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Fixed typo in help text [`c02953e`](https://github.com/RhetTbull/osxphotos/commit/c02953ef5fe1aee219e0557bfd8c3322f1900a81)
#### [v0.24.2](https://github.com/RhetTbull/osxphotos/compare/v0.24.1...v0.24.2)
@@ -212,8 +365,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 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)
- Updated example [`8f0307f`](https://github.com/RhetTbull/osxphotos/commit/8f0307fc24345ca0e87017ac76791c9bbe8db25e)
#### [v0.23.3](https://github.com/RhetTbull/osxphotos/compare/v0.23.1...v0.23.3)
@@ -228,15 +381,15 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 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)
- still trying to debug github actions fail [`960487f`](https://github.com/RhetTbull/osxphotos/commit/960487f2961f97f6b24d253472dcedf74dfc7797)
#### [v0.23.0](https://github.com/RhetTbull/osxphotos/compare/v0.22.23...v0.23.0)
> 21 March 2020
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`21547a8`](https://github.com/RhetTbull/osxphotos/commit/21547a8eaad117b11bc5e4dddf95436a8244e9ba)
- 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)
@@ -252,7 +405,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- test library updates [`e99391a`](https://github.com/RhetTbull/osxphotos/commit/e99391a68e844adb63edde3efb921cffa3928aeb)
#### [v0.22.17](https://github.com/RhetTbull/osxphotos/compare/v0.22.16...v0.22.17)
@@ -267,18 +420,17 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Fixed bug in --download-missing related to burst images [`1f13ba8`](https://github.com/RhetTbull/osxphotos/commit/1f13ba837fe36ff4eeb48cca02f5312a88a0a765)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`dc87194`](https://github.com/RhetTbull/osxphotos/commit/dc87194eec252461d0cc0891b9ede4157125e828)
- 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)
@@ -295,7 +447,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Cleaned up comments and unneeded test code [`e3c40bc`](https://github.com/RhetTbull/osxphotos/commit/e3c40bcbaaf3560d53091cf46ed851d90ff82cfa)
#### [v0.22.9](https://github.com/RhetTbull/osxphotos/compare/v0.22.7...v0.22.9)
@@ -307,7 +459,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Added PhotosDB() behavior to open last library if no args passed but also added cautionary note to README [`46d3c7d`](https://github.com/RhetTbull/osxphotos/commit/46d3c7dbdaf848d5c340ce8a362ff296a36c552d)
#### [v0.22.7](https://github.com/RhetTbull/osxphotos/compare/v0.22.4...v0.22.7)
@@ -321,7 +473,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`898d3af`](https://github.com/RhetTbull/osxphotos/commit/898d3afc0892546ece6c3d675208dea216e20633)
#### [v0.22.4](https://github.com/RhetTbull/osxphotos/compare/v0.22.0...v0.22.4)
@@ -333,7 +485,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`7150956`](https://github.com/RhetTbull/osxphotos/commit/7150956a488677d402a6d43443d04c4b11dc7be0)
#### [v0.22.0](https://github.com/RhetTbull/osxphotos/compare/v0.21.5...v0.22.0)
@@ -341,7 +493,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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)
- Fix to setup to specify versions of required packages [`de05323`](https://github.com/RhetTbull/osxphotos/commit/de05323a153fe49723b39e48b9038c1fb9535a72)
#### [v0.21.5](https://github.com/RhetTbull/osxphotos/compare/v0.21.0...v0.21.5)
@@ -356,8 +508,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 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)
- Initial support for live photos (Photos 5 only) [`1a89a18`](https://github.com/RhetTbull/osxphotos/commit/1a89a18a011a25616d7a18fb9bf1270b0b206fb4)
#### [v0.20.0](https://github.com/RhetTbull/osxphotos/compare/v0.19.0...v0.20.0)
@@ -371,9 +523,9 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 29 December 2019
- Merge branch 'master' of https://github.com/RhetTbull/osxphotos [`51843fb`](https://github.com/RhetTbull/osxphotos/commit/51843fb46d6ce69456400271c97aa642466d5719)
- 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)
@@ -387,11 +539,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
- 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.15.0...v0.15.1)
> 23 May 2020
#### [v0.15.0](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.0)
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.1)
> 14 December 2019

230
README.md
View File

@@ -17,6 +17,7 @@
+ [AlbumInfo](#albuminfo)
+ [FolderInfo](#folderinfo)
+ [PlaceInfo](#placeinfo)
+ [ScoreInfo](#scoreinfo)
+ [Template Substitutions](#template-substitutions)
+ [Utility Functions](#utility-functions)
* [Examples](#examples)
@@ -34,7 +35,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib
## Supported operating systems
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 & 10.15.4 / Photos 5.0.
Only works on MacOS (aka Mac OS X). Tested on MacOS 10.12.6 / Photos 2.0, 10.13.6 / Photos 3.0, MacOS 10.14.5, 10.14.6 / Photos 4.0, MacOS 10.15.1 - 10.15.5 / Photos 5.0.
Requires python >= 3.8. You can probably get this to run with Python 3.6 or 3.7 (see notes [below](#Installation-instructions)) but only 3.8+ is officially supported.
@@ -59,7 +60,7 @@ You can also install directly from [pypi](https://pypi.org/) but you must use py
This package will install a command line utility called `osxphotos` that allows you to query the Photos database. Alternatively, you can also run the command line utility like this: `python3 -m osxphotos`
If you only care about the command line tool, I recommend installing with [pipx](https://github.com/pipxproject/pipx)
If you only care about the command line tool, you can download an executable of the latest [release](https://github.com/RhetTbull/osxphotos/releases). Alternatively, I recommend installing with [pipx](https://github.com/pipxproject/pipx)
After installing pipx:
`pipx install osxphotos`
@@ -90,6 +91,7 @@ Commands:
help Print help; for help on commands: help <command>.
info Print out descriptive info of the Photos library database.
keywords Print out keywords found in the Photos library.
labels Print out image classification labels found in the Photos...
list Print list of Photos libraries found on the system.
persons Print out persons (faces) found in the Photos library.
places Print out places found in the Photos library.
@@ -125,13 +127,13 @@ Options:
-V, --verbose Print verbose output.
--keyword KEYWORD Search for photos with keyword KEYWORD. If
more than one keyword, treated as "OR", e.g.
find photos match any keyword
find photos matching any keyword
--person PERSON Search for photos with person PERSON. If
more than one person, treated as "OR", e.g.
find photos match any person
find photos matching any person
--album ALBUM Search for photos in album ALBUM. If more
than one album, treated as "OR", e.g. find
photos match any album
photos matching any album
--folder FOLDER Search for photos in an album in folder
FOLDER. If more than one folder, treated as
"OR", e.g. find photos in any FOLDER. Only
@@ -146,11 +148,15 @@ Options:
geolocation info
--no-place Search for photos with no associated place
name info (no reverse geolocation info)
--label LABEL Search for photos with image classification
label LABEL (Photos 5 only). If more than
one label, treated as "OR", e.g. find photos
matching any label
--uti UTI Search for photos whose uniform type
identifier (UTI) matches UTI
-i, --ignore-case Case insensitive search for title,
description, or place. Does not apply to
keyword, person, or album.
description, place, keyword, person, or
album.
--edited Search for photos that have been edited.
--external-edit Search for photos edited in external editor.
--favorite Search for photos marked favorite.
@@ -201,8 +207,14 @@ Options:
Search by end item date, e.g.
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601
w/o TZ).
--deleted Include photos from the 'Recently Deleted'
folder.
--deleted-only Include only photos from the 'Recently
Deleted' folder.
--update Only export new or updated files. See notes
below on export and --update.
--dry-run Dry run (test) the export but don't actually
export any files; most useful with --verbose
--export-as-hardlink Hardlink files instead of copying them.
Cannot be used with --exiftool which creates
copies of the files with embedded EXIF data.
@@ -280,6 +292,19 @@ Options:
output directory in the form
'{name,DEFAULT}'. See below for additional
details on templating system.
--filename FILENAME Optional template for specifying name of
output file in the form '{name,DEFAULT}'.
File extension will be added automatically--
do not include an extension in the FILENAME
template. See below for additional details
on templating system.
--edited-suffix SUFFIX Optional suffix for naming edited photos.
Default name for edited photos is in form
'photoname_edited.ext'. For example, with '
--edited-suffix _bearbeiten', the edited
photo would be named
'photoname_bearbeiten.ext'. The default
suffix is '_edited'.
--no-extended-attributes Don't copy extended attributes when
exporting. You only need this if exporting
to a filesystem that doesn't support Mac OS
@@ -317,13 +342,16 @@ option to re-export the entire library thus rebuilding the
** Templating System **
With the --directory option you may specify a template for the export
directory. This directory will be appended to the export path specified in
the export DEST argument to export. For example, if template is
'{created.year}/{created.month}', and export desitnation DEST is
'/Users/maria/Pictures/export', the actual export directory for a photo would
be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
2020.
With the --directory and --filename options you may specify a template for the
export directory or filename, respectively. The directory will be appended to
the export path specified in the export DEST argument to export. For example,
if template is '{created.year}/{created.month}', and export desitnation DEST
is '/Users/maria/Pictures/export', the actual export directory for a photo
would be '/Users/maria/Pictures/export/2020/March' if the photo was created in
March 2020. Some template substitutions may result in more than one value, for
example '{album}' if photo is in more than one album or '{keyword}' if photo
has more than one keyword. In this case, more than one copy of the photo will
be exported, each in a separate directory or with a different filename.
The templating system may also be used with the --keyword-template option to
set keywords on export (with --exiftool or --sidecar), for example, to set a
@@ -349,9 +377,8 @@ contain a brace symbol ('{' or '}').
If you do not specify a default value and the template substitution has no
value, '_' (underscore) will be used as the default value. For example, in the
above example, this would result in '2020/_/photoname.jpg' if address was null
I plan to eventually extend the templating system to the exported filename so
you can specify the filename using a template.
above example, this would result in '2020/_/photoname.jpg' if address was
null.
Substitution Description
{name} Current filename of the photo
@@ -369,8 +396,24 @@ Substitution Description
creation time
{created.mon} Month abbreviation in the user's locale of
the file creation time
{created.dd} 2-digit day of the month (zero padded) of
file creation time
{created.dow} Day of week in 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)
{created.hour} 2-digit hour of the file creation time
{created.min} 2-digit minute of the file creation time
{created.sec} 2-digit second of the file creation time
{created.strftime} Apply strftime template to file creation
date/time. Should be used in form
{created.strftime,TEMPLATE} where TEMPLATE
is a valid strftime template, e.g.
{created.strftime,%Y-%U} would result in
year-week number of year: '2020-23'. If used
with no template will return null value. See
https://strftime.org/ for help on strftime
templates.
{modified.date} Photo's modification date in ISO format,
e.g. '2020-03-22'
{modified.year} 4-digit year of file modification time
@@ -381,9 +424,42 @@ Substitution Description
modification time
{modified.mon} Month abbreviation in the user's locale of
the file modification time
{modified.dd} 2-digit day of the month (zero padded) 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)
{modified.hour} 2-digit hour of the file modification time
{modified.min} 2-digit minute of the file modification time
{modified.sec} 2-digit second of the file modification time
{today.date} Current date in iso format, e.g.
'2020-03-22'
{today.year} 4-digit year of current date
{today.yy} 2-digit year of current date
{today.mm} 2-digit month of the current date (zero
padded)
{today.month} Month name in user's locale of the current
date
{today.mon} Month abbreviation in the user's locale of
the current date
{today.dd} 2-digit day of the month (zero padded) of
current date
{today.dow} Day of week in user's locale of the current
date
{today.doy} 3-digit day of year (e.g Julian day) of
current date, starting from 1 (zero padded)
{today.hour} 2-digit hour of the current date
{today.min} 2-digit minute of the current date
{today.sec} 2-digit second of the current date
{today.strftime} Apply strftime template to current
date/time. Should be used in form
{today.strftime,TEMPLATE} where TEMPLATE is
a valid strftime template, e.g.
{today.strftime,%Y-%U} would result in year-
week number of year: '2020-23'. If used with
no template will return null value. See
https://strftime.org/ for help on strftime
templates.
{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
@@ -448,6 +524,10 @@ Example: export photos to file structure based on 4-digit year and full name of
`osxphotos export ~/Desktop/export --directory "{created.year}/{created.month}"`
Example: export default library using 'country name/year' as output directory (but use "NoCountry/year" if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput
`osxphotos export ~/Desktop/export --directory "{place.name.country,NoCountry}/{created.year}" --person-keyword --album-keyword --keyword-template "{created.year}" --exiftool --update --verbose`
## Example uses of the package
@@ -785,8 +865,25 @@ photosdb.db_version
Returns the version number for Photos library database. You likely won't need this but it's provided in case needed for debugging. PhotosDB will print a warning to `sys.stderr` if you open a database version that has not been tested.
#### `get_db_connection()`
Returns tuple of (connection, cursor) for the working copy of the Photos database. This is useful for debugging or prototyping new features.
#### ` photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=False, from_date=None, to_date=None)`
```python
photosdb = osxphotos.PhotosDB()
conn, cursor = photosdb.get_db_connection()
results = conn.execute(
"SELECT ZUUID FROM ZGENERICASSET WHERE ZFAVORITE = 1;"
).fetchall()
for row in results:
# do something
pass
conn.close()
```
#### ` photos(keywords=None, uuid=None, persons=None, albums=None, images=True, movies=True, from_date=None, to_date=None, intrash=False)`
```python
# assumes photosdb is a PhotosDB object (see above)
@@ -807,7 +904,8 @@ photos = photosdb.photos(
images = bool,
movies = bool,
from_date = datetime.datetime,
to_date = datetime.datetime
to_date = datetime.datetime,
intrash = bool,
)
```
@@ -816,9 +914,10 @@ photos = photosdb.photos(
- ```persons```: list of one or more persons. Returns only photos containing the person(s). If more than one person provided, returns photos that match any of the persons (e.g. treated as "or")
- ```albums```: list of one or more album names. Returns only photos contained in the album(s). If more than one album name is provided, returns photos contained in any of the albums (.e.g. treated as "or")
- ```images```: bool; if True, returns photos/images; default is True
- ```movies```: bool; if True, returns movies/videos; default is False
- ```movies```: bool; if True, returns movies/videos; default is True
- ```from_date```: datetime.datetime; if provided, finds photos where creation date >= from_date; default is None
- ```to_date```: datetime.datetime; if provided, finds photos where creation date <= to_date; default is None
- ```intrash```: if True, finds only photos in the "Recently Deleted" or trash folder, if False does not find any photos in the trash; default is False
If more than one of (keywords, uuid, persons, albums,from_date, to_date) is provided, they are treated as "and" criteria. E.g.
@@ -861,15 +960,11 @@ photos2 = photosdb.photos(keywords=["Kids"])
photos3 = [p for p in photos2 if p not in photos1]
```
By default, photos() only returns images, not movies. To also get movies, pass movies=True:
```python
photos_and_movies = photosdb.photos(movies=True)
```
To get only movies:
```python
movies = photosdb.photos(images=False, movies=True)
```
**Note** PhotosDB.photos() may return a different number of photos than Photos.app reports in the GUI. This is because photos() returns [hidden](#hidden) photos, [shared](#shared) photos, and for [burst](#burst) photos, all selected burst images even if non-selected burst images have not been deleted. Photos only reports 1 single photo for each set of burst images until you "finalize" the burst by selecting key photos and deleting the others using the "Make a selection" option.
For example, in my library, Photos says I have 19,386 photos and 474 movies. However, PhotosDB.photos() reports 25,002 photos. The difference is due to 5,609 shared photos and 7 hidden photos. (*Note* Shared photos only valid for Photos 5). Similarly, filtering for just movies returns 625 results. The difference between 625 and 474 reported by Photos is due to 151 shared movies.
@@ -958,6 +1053,9 @@ Returns `True` if the picture has been marked as a favorite, otherwise `False`
#### `hidden`
Returns `True` if the picture has been marked as hidden, otherwise `False`
#### `intrash`
Returns `True` if the picture is in the trash ('Recently Deleted' folder), otherwise `False`
#### `location`
Returns latitude and longitude as a tuple of floats (latitude, longitude). If location is not set, latitude and longitude are returned as `None`
@@ -1106,7 +1204,12 @@ photo.exiftool.setvalue("XMP:Title", "Title of photo")
photo.exiftool.addvalues("IPTC:Keywords", "vacation", "beach")
```
**Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.as_dict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`.
**Caution**: I caution against writing new EXIF data to photos in the Photos library because this will overwrite the original copy of the photo and could adversely affect how Photos behaves. `exiftool.as_dict()` is useful for getting access to all the photos information but if you want to write new EXIF data, I recommend you export the photo first then write the data. [PhotoInfo.export()](#export) does this if called with `exiftool=True`.
#### `score`
Returns a [ScoreInfo](#scoreinfo) data class object which provides access to the computed aesthetic scores for each photo.
**Note**: Valid only for Photos 5; returns None for earlier Photos versions.
#### `json()`
Returns a JSON representation of all photo info
@@ -1153,11 +1256,13 @@ If overwrite=False and increment=False, export will fail if destination file alr
#### <a name="rendertemplate">`render_template()`</a>
`render_template(template, none_str = "_", path_sep = None)`
`render_template(template_str, none_str = "_", path_sep = None, expand_inplace = False, inplace_sep = None)`
Render template string for photo. none_str is used if template substitution results in None value and no default specified.
- `template`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
- `template_str`: str in form "{name,DEFAULT}" where name is one of the values in table below. The "," and default value that follows are optional. If specified, "DEFAULT" will be used if "name" is None. This is useful for values which are not always present, for example reverse geolocation data.
- `none_str`: optional str to use as substitution when template value is None and no default specified in the template string. default is "_".
- `path_sep`: optional character to use as path separator, default is os.path.sep
- `expand_inplace`: expand multi-valued substitutions in-place as a single string instead of returning individual strings
- `inplace_sep`: optional string to use as separator between multi-valued keywords with expand_inplace; default is ','
Returns a tuple of (rendered, unmatched) where rendered is a list of rendered strings with all substitutions made and unmatched is a list of any strings that resembled a template substitution but did not match a known substitution. E.g. if template contained "{foo}", unmatched would be ["foo"].
@@ -1352,11 +1457,49 @@ PostalAddress(street='3700 Wailea Alanui Dr', sub_locality=None, city='Kihei', s
>>> photo.place.address.postal_code
'96753'
```
### ScoreInfo
[PhotoInfo.score](#score) returns a ScoreInfo object that exposes the computed aesthetic scores for each photo (**Photos 5 only**). I have not yet reverse engineered the meaning of each score. The `overall` score seems to the most useful and appears to be a composite of the other scores. The following score properties are currently available:
```python
overall: float
curation: float
promotion: float
highlight_visibility: float
behavioral: float
failure: float
harmonious_color: float
immersiveness: float
interaction: float
interesting_subject: float
intrusive_object_presence: float
lively_color: float
low_light: float
noise: float
pleasant_camera_tilt: float
pleasant_composition: float
pleasant_lighting: float
pleasant_pattern: float
pleasant_perspective: float
pleasant_post_processing: float
pleasant_reflection: float
pleasant_symmetry: float
sharply_focused_subject: float
tastefully_blurred: float
well_chosen_subject: float
well_framed_subject: float
well_timed_shot: float
```
Example: find your "best" photo of food
```python
>>> import osxphotos
>>> photos = osxphotos.PhotosDB().photos()
>>> best_food_photo = sorted([p for p in photos if "food" in p.labels_normalized], key=lambda p: p.score.overall, reverse=True)[0]
```
### Template Substitutions
The following substitutions are availabe for use with `PhotoInfo.render_template()`
| Substitution | Description |
|--------------|-------------|
|{name}|Current filename of the photo|
@@ -1369,14 +1512,37 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|{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.dd}|2-digit day of the month (zero padded) of file creation time|
|{created.dow}|Day of week in 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)|
|{created.hour}|2-digit hour of the file creation time|
|{created.min}|2-digit minute of the file creation time|
|{created.sec}|2-digit second of the file creation time|
|{created.strftime}|Apply strftime template to file creation date/time. Should be used in form {created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates.|
|{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.dd}|2-digit day of the month (zero padded) 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)|
|{modified.hour}|2-digit hour of the file modification time|
|{modified.min}|2-digit minute of the file modification time|
|{modified.sec}|2-digit second of the file modification time|
|{today.date}|Current date in iso format, e.g. '2020-03-22'|
|{today.year}|4-digit year of current date|
|{today.yy}|2-digit year of current date|
|{today.mm}|2-digit month of the current date (zero padded)|
|{today.month}|Month name in user's locale of the current date|
|{today.mon}|Month abbreviation in the user's locale of the current date|
|{today.dd}|2-digit day of the month (zero padded) of current date|
|{today.dow}|Day of week in user's locale of the current date|
|{today.doy}|3-digit day of year (e.g Julian day) of current date, starting from 1 (zero padded)|
|{today.hour}|2-digit hour of the current date|
|{today.min}|2-digit minute of the current date|
|{today.sec}|2-digit second of the current date|
|{today.strftime}|Apply strftime template to current date/time. Should be used in form {today.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. {today.strftime,%Y-%U} would result in year-week number of year: '2020-23'. If used with no template will return null value. See https://strftime.org/ for help on strftime templates.|
|{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|
@@ -1397,7 +1563,6 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|{label}|Image categorization label associated with a photo (Photos 5 only)|
|{label_normalized}|All lower case version of 'label' (Photos 5 only)|
### Utility Functions
The following functions are located in osxphotos.utils
@@ -1421,11 +1586,6 @@ Convert latitude, longitude in degrees to degrees, minutes, seconds as string.
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.
#### `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. Useful for exporting photos to a date-based folder structure.
## Examples

View File

@@ -7,6 +7,7 @@
# If you run this using python from command line, do so with -i flag:
# python3 -i examples/photos_repl.py
import os
import sys
import time
@@ -17,14 +18,27 @@ import osxphotos
from osxphotos.__main__ import get_photos_db, _list_libraries
def show(photo):
""" open image with default image viewer
Note: This is for debugging only -- it will actually open any filetype which could
be very, very bad.
Args:
photo: PhotoInfo object or a path to a photo on disk
"""
photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo
if not os.path.isfile(photopath):
return f"'{photopath}' does not appear to be a valid photo path"
os.system(f"open '{photopath}'")
def main():
db = None
if len(sys.argv) > 1:
db = sys.argv[1]
else:
db = get_photos_db()
db = sys.argv[1] if len(sys.argv) > 1 else get_photos_db()
if db:
print("loading database")
tic = time.perf_counter()

View File

@@ -3,14 +3,9 @@ import logging
from ._version import __version__
from .photoinfo import PhotoInfo
from .photosdb import PhotosDB
from .utils import _set_debug, _debug, _get_logger
from .phototemplate import PhotoTemplate
from .utils import _debug, _get_logger, _set_debug
# 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: Add special albums and magic albums
# TODO: cleanup os.path and pathlib code (import pathlib and also from pathlib import Path)

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,7 @@ _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
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.5
# which major version operating systems have been tested
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]

View File

@@ -9,6 +9,7 @@ import pathlib
import sqlite3
import sys
from abc import ABC, abstractmethod
from io import StringIO
from sqlite3 import Error
from ._version import __version__
@@ -17,6 +18,8 @@ OSXPHOTOS_EXPORTDB_VERSION = "1.0"
class ExportDB_ABC(ABC):
""" abstract base class for ExportDB """
@abstractmethod
def get_uuid_for_file(self, filename):
pass
@@ -58,7 +61,7 @@ class ExportDB_ABC(ABC):
pass
@abstractmethod
def set_data(self, file, uuid, orig_stat, exif_stat, info_json, exif_json):
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
pass
@@ -95,7 +98,7 @@ class ExportDBNoOp(ExportDB_ABC):
def set_exifdata_for_file(self, uuid, exifdata):
pass
def set_data(self, file, uuid, orig_stat, exif_stat, info_json, exif_json):
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
pass
@@ -302,21 +305,41 @@ class ExportDB(ExportDB_ABC):
logging.debug(f"set_exifdata: {filename}, {exifdata}")
def set_data(self, file, uuid, orig_stat, exif_stat, info_json, exif_json):
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
""" sets all the data for file and uuid at once
calls set_uuid_for_file
set_info_for_uuid
set_stat_orig_for_file
set_stat_exif_for_file
set_exifdata_for_file
"""
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
self.set_uuid_for_file(filename, uuid)
self.set_info_for_uuid(uuid, info_json)
self.set_stat_orig_for_file(filename, orig_stat)
self.set_stat_exif_for_file(filename, exif_stat)
self.set_exifdata_for_file(filename, exif_json)
filename = str(pathlib.Path(filename).relative_to(self._path))
filename_normalized = filename.lower()
conn = self._conn
try:
c = conn.cursor()
c.execute(
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
(filename, filename_normalized, uuid),
)
c.execute(
"UPDATE files "
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*orig_stat, filename_normalized),
)
c.execute(
"UPDATE files "
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
+ "WHERE filepath_normalized = ?;",
(*exif_stat, filename_normalized),
)
c.execute(
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
(uuid, info_json),
)
c.execute(
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
(filename_normalized, exif_json),
)
conn.commit()
except Error as e:
logging.warning(e)
def close(self):
""" close the database connection """
@@ -422,10 +445,7 @@ class ExportDB(ExportDB_ABC):
dt = datetime.datetime.utcnow().isoformat()
python_path = sys.executable
cmd = sys.argv[0]
if len(sys.argv) > 1:
args = " ".join(sys.argv[1:])
else:
args = ""
args = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else ""
cwd = os.getcwd()
conn = self._conn
try:
@@ -437,3 +457,64 @@ class ExportDB(ExportDB_ABC):
conn.commit()
except Error as e:
logging.warning(e)
class ExportDBInMemory(ExportDB):
""" In memory version of ExportDB
Copies the on-disk database into memory so it may be operated on without
modifying the on-disk verison
"""
def init(self, dbfile):
self._dbfile = dbfile
# _path is parent of the database
# all files referenced by get_/set_uuid_for_file will be converted to
# relative paths to this parent _path
# this allows the entire export tree to be moved to a new disk/location
# whilst preserving the UUID to filename mappping
self._path = pathlib.Path(dbfile).parent
self._conn = self._open_export_db(dbfile)
self._insert_run_info()
def _open_export_db(self, dbfile):
""" open export database and return a db connection
if dbfile does not exist, will create and initialize the database
returns: connection to the database
"""
if not os.path.isfile(dbfile):
logging.debug(f"dbfile {dbfile} doesn't exist, creating in memory version")
conn = self._get_db_connection()
if conn:
self._create_db_tables(conn)
else:
raise Exception("Error getting connection to in-memory database")
else:
logging.debug(f"dbfile {dbfile} exists, opening it and copying to memory")
try:
conn = sqlite3.connect(dbfile)
except Error as e:
logging.warning(e)
raise e
tempfile = StringIO()
for line in conn.iterdump():
tempfile.write("%s\n" % line)
conn.close()
tempfile.seek(0)
# Create a database in memory and import from tempfile
conn = sqlite3.connect(":memory:")
conn.cursor().executescript(tempfile.read())
conn.commit()
return conn
def _get_db_connection(self):
""" return db connection to in memory database """
try:
conn = sqlite3.connect(":memory:")
except Error as e:
logging.warning(e)
conn = None
return conn

View File

@@ -1,54 +0,0 @@
"""Utilities for comparing files
Modified from CPython/Lib/filecmp.py
Functions:
cmp_file(f1, s2) -> int
file_sig(f1) -> Tuple[int, int, float]
"""
import os
import stat
__all__ = ["cmp", "sig"]
def cmp_file(f1, s2):
"""Compare file f1 to signature s2.
Arguments:
f1 -- File name
s2 -- stats as returned by sig
Return value:
True if the files are the same, False otherwise.
This function uses a cache for past comparisons and the results,
with cache entries invalidated if their stat information
changes. The cache may be cleared by calling clear_cache().
"""
if not s2:
return False
s1 = _sig(os.stat(f1))
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
return False
if s1 == s2:
return True
return False
def _sig(st):
return (stat.S_IFMT(st.st_mode), st.st_size, st.st_mtime)
def file_sig(f1):
""" return os.stat signature for file f1 """
return _sig(os.stat(f1))

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.29.2"
__version__ = "0.30.3"

View File

@@ -62,10 +62,7 @@ class AlbumInfo:
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)
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
return self._folder_names
@property

View File

@@ -12,41 +12,59 @@ class DateTimeFormatter:
@property
def date(self):
""" ISO date in form 2020-03-22 """
date = self.dt.date().isoformat()
return date
return self.dt.date().isoformat()
@property
def year(self):
""" 4 digit year """
year = f"{self.dt.year}"
return year
return f"{self.dt.year}"
@property
def yy(self):
""" 2 digit year """
yy = f"{self.dt.strftime('%y')}"
return yy
return f"{self.dt.strftime('%y')}"
@property
def mm(self):
""" 2 digit month """
mm = f"{self.dt.strftime('%m')}"
return mm
return f"{self.dt.strftime('%m')}"
@property
def month(self):
""" Month as locale's full name """
month = f"{self.dt.strftime('%B')}"
return month
return f"{self.dt.strftime('%B')}"
@property
def mon(self):
""" Month as locale's abbreviated name """
mon = f"{self.dt.strftime('%b')}"
return mon
return f"{self.dt.strftime('%b')}"
@property
def dd(self):
""" 2-digit day of the month """
return f"{self.dt.strftime('%d')}"
@property
def dow(self):
""" Day of week as locale's name """
return f"{self.dt.strftime('%A')}"
@property
def doy(self):
""" Julian day of year starting from 001 """
doy = f"{self.dt.strftime('%j')}"
return doy
return f"{self.dt.strftime('%j')}"
@property
def hour(self):
""" 2-digit hour """
return f"{self.dt.strftime('%H')}"
@property
def min(self):
""" 2-digit minute """
return f"{self.dt.strftime('%M')}"
@property
def sec(self):
""" 2-digit second """
return f"{self.dt.strftime('%S')}"

View File

@@ -59,11 +59,7 @@ class _ExifToolProc:
)
return
if exiftool:
self._exiftool = exiftool
else:
self._exiftool = get_exiftool_path()
self._exiftool = exiftool if exiftool else get_exiftool_path()
self._process_running = False
self._start_proc()
@@ -156,8 +152,7 @@ class ExifTool:
if value is None:
value = ""
command = []
command.append(f"-{tag}={value}")
command = [f"-{tag}={value}"]
if self.overwrite:
command.append("-overwrite_original")
self.run_commands(*command)
@@ -193,7 +188,7 @@ class ExifTool:
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:
if not (hasattr(self, "_process") and self._process):
raise ValueError("exiftool process is not running")
if not commands:
@@ -245,8 +240,7 @@ class ExifTool:
def json(self):
""" returns JSON string containing all EXIF tags and values from exiftool """
json_str = self.run_commands("-json")
return json_str
return self.run_commands("-json")
def _read_exif(self):
""" read exif data from file """
@@ -254,5 +248,4 @@ class ExifTool:
self.data = {k: v for k, v in data.items()}
def __str__(self):
str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
return str_
return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"

178
osxphotos/fileutil.py Normal file
View File

@@ -0,0 +1,178 @@
""" FileUtil class with methods for copy, hardlink, unlink, etc. """
import logging
import os
import pathlib
import stat
import subprocess
import sys
from abc import ABC, abstractmethod
class FileUtilABC(ABC):
""" Abstract base class for FileUtil """
@classmethod
@abstractmethod
def hardlink(cls, src, dest):
pass
@classmethod
@abstractmethod
def copy(cls, src, dest, norsrc=False):
pass
@classmethod
@abstractmethod
def unlink(cls, dest):
pass
@classmethod
@abstractmethod
def cmp_sig(cls, file1, file2):
pass
@classmethod
@abstractmethod
def file_sig(cls, file1):
pass
class FileUtilMacOS(FileUtilABC):
""" Various file utilities """
@classmethod
def hardlink(cls, src, dest):
""" Hardlinks a file from src path to dest path
src: source path as string
dest: destination path as string
Raises exception if linking 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 error on copy, subprocess will raise CalledProcessError
try:
os.link(src, dest)
except Exception as e:
logging.critical(f"os.link returned error: {e}")
raise e
@classmethod
def copy(cls, 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
@classmethod
def unlink(cls, filepath):
""" unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink """
if isinstance(filepath, pathlib.Path):
filepath.unlink()
else:
os.unlink(filepath)
@classmethod
def cmp_sig(cls, f1, s2):
"""Compare file f1 to signature s2.
Arguments:
f1 -- File name
s2 -- stats as returned by sig
Return value:
True if the files are the same, False otherwise.
"""
if not s2:
return False
s1 = cls._sig(os.stat(f1))
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
return False
return s1 == s2
@classmethod
def file_sig(cls, f1):
""" return os.stat signature for file f1 """
return cls._sig(os.stat(f1))
@staticmethod
def _sig(st):
return (stat.S_IFMT(st.st_mode), st.st_size, st.st_mtime)
class FileUtil(FileUtilMacOS):
""" Various file utilities """
pass
class FileUtilNoOp(FileUtil):
""" No-Op implementation of FileUtil for testing / dry-run mode
all methods with exception of cmp_sig and file_cmp are no-op
cmp_sig functions as FileUtil.cmp_sig does
file_cmp returns mock data
"""
@staticmethod
def noop(*args):
pass
verbose = noop
def __new__(cls, verbose=None):
if verbose:
if callable(verbose):
cls.verbose = verbose
else:
raise ValueError(f"verbose {verbose} not callable")
return super(FileUtilNoOp, cls).__new__(cls)
@classmethod
def hardlink(cls, src, dest):
cls.verbose(f"hardlink: {src} {dest}")
@classmethod
def copy(cls, src, dest, norsrc=False):
cls.verbose(f"copy: {src} {dest}")
@classmethod
def unlink(cls, dest):
cls.verbose(f"unlink: {dest}")
@classmethod
def file_sig(cls, file1):
cls.verbose(f"file_sig: {file1}")
return (42, 42, 42)

View File

@@ -6,4 +6,5 @@ PhotosDB.photos() returns a list of PhotoInfo objects
from ._photoinfo_exifinfo import ExifInfo
from ._photoinfo_export import ExportResults
from ._photoinfo_scoreinfo import ScoreInfo
from .photoinfo import PhotoInfo

View File

@@ -1,4 +1,13 @@
""" export methods for PhotoInfo """
""" Export methods for PhotoInfo
The following methods are defined and must be imported into PhotoInfo as instance methods:
export
export2
_export_photo
_write_exif_data
_exiftool_json_sidecar
_xmp_sidecar
_write_sidecar
"""
# TODO: should this be its own PhotoExporter class?
@@ -9,10 +18,12 @@ import logging
import os
import pathlib
import re
import tempfile
from collections import namedtuple # pylint: disable=syntax-error
from mako.template import Template
from .._applescript import AppleScript
from .._constants import (
_MAX_IPTC_KEYWORD_LEN,
_OSXPHOTOS_NONE_SENTINEL,
@@ -20,21 +31,171 @@ from .._constants import (
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
)
from ..exiftool import ExifTool
from .._export_db import ExportDBNoOp
from .._filecmp import cmp_file, file_sig
from ..utils import (
_copy_file,
_export_photo_uuid_applescript,
_hardlink_file,
dd_to_dms_str,
)
from ..exiftool import ExifTool
from ..fileutil import FileUtil
from ..utils import dd_to_dms_str
ExportResults = namedtuple(
"ExportResults", ["exported", "new", "updated", "skipped", "exif_updated"]
)
# _export_photo_uuid_applescript is not a class method, don't import this into PhotoInfo
def _export_photo_uuid_applescript(
uuid,
dest,
filestem=None,
original=True,
edited=False,
live_photo=False,
timeout=120,
burst=False,
dry_run=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
dry_run: (boolean) set to True to run in "dry run" mode which will download file but not actually copy to destination
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(f"Error exporting uuid {uuid}: {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}")
if not dry_run:
FileUtil.copy(str(path), str(dest_new))
exported_paths.append(str(dest_new))
return exported_paths
else:
return None
# _check_export_suffix is not a class method, don't import this into PhotoInfo
def _check_export_suffix(src, dest, edited):
"""Helper function for exporting photos to check file extensions of destination path.
Checks that dst file extension is appropriate for the src.
If edited=True, will use src file extension of ".jpeg" if None provided for src.
Args:
src: path to source file or None.
dest: path to destination file.
edited: set to True if exporting an edited photo.
Returns:
True if src and dest extensions are OK, else False.
Raises:
ValueError if edited is False and src is None
"""
# check extension of destination
if src is not None:
# use suffix from edited file
actual_suffix = pathlib.Path(src).suffix
elif edited:
# use .jpeg as that's probably correct
actual_suffix = ".jpeg"
else:
raise ValueError("src must not be None if edited=False")
# Photo's often converts .JPG to .jpeg or .tif to .tiff on import
dest_ext = dest.suffix.lower()
actual_ext = actual_suffix.lower()
suffixes = sorted([dest_ext, actual_ext])
return (
dest_ext == actual_ext
or suffixes == [".jpeg", ".jpg"]
or suffixes == [".tif", ".tiff"]
)
def export(
self,
dest,
@@ -54,6 +215,7 @@ def export(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" export photo
dest: must be valid destination path (or exception raised)
@@ -61,8 +223,11 @@ def export(
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
export will print a warning but will happily export the photo using the
incorrect file extension. e.g. to get the extension of the edited photo,
export will print a warning but will export the photo using the
incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
@@ -86,6 +251,7 @@ def export(
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
returns: list of photos exported
"""
@@ -109,6 +275,7 @@ def export(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
return results.exported
@@ -133,17 +300,23 @@ def export2(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
update=False,
export_db=None,
fileutil=FileUtil,
dry_run=False,
):
""" export photo
dest: must be valid destination path (or exception raised)
""" export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
filename: (optional): name of exported picture; if not provided, will use current filename
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
For example, if photo is .CR2 file, edited image may be .jpeg.
If you provide an extension different than what the actual file is,
export will print a warning but will happily export the photo using the
incorrect file extension. e.g. to get the extension of the edited photo,
export will print a warning but will export the photo using the
incorrect file extension (unless use_photos_export is true, in which case export will
use the extension provided by Photos upon export; in this case, an incorrect extension is
silently ignored).
e.g. to get the extension of the edited photo,
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
@@ -167,16 +340,22 @@ def export2(
use_persons_as_keywords: (boolean, default = False); if True, will include person names in keywords
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
update: (boolean, default=False); if True export will run in update mode, that is, it will
not export the photo if the current version already exists in the destination
export_db: (ExportDB_ABC); instance of a class that conforms to ExportDB_ABC with methods
for getting/setting data related to exported files to compare update state
fileutil: (FileUtilABC); class that conforms to FileUtilABC with various file utilities
dry_run: (boolean, default=False); set to True to run in "dry run" mode
Returns: ExportResults namedtuple with fields: exported, new, updated, skipped
where each field is a list of file paths
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
"""
# if update, caller may pass function refs to get/set uuid for file being exported
# and for setting/getting the PhotoInfo json info for an exported file
# when called from export(), won't get an export_db, so use no-op version
if export_db is None:
export_db = ExportDBNoOp()
@@ -212,7 +391,7 @@ def export2(
# verify destination is a valid path
if dest is None:
raise ValueError("Destination must not be None")
elif not os.path.isdir(dest):
elif not dry_run and not os.path.isdir(dest):
raise FileNotFoundError("Invalid path passed to export")
if filename and len(filename) == 1:
@@ -242,27 +421,6 @@ def export2(
fname = pathlib.Path(fname)
dest = dest / fname
# check extension of destination
if edited and self.path_edited is not None:
# use suffix from edited file
actual_suffix = pathlib.Path(self.path_edited).suffix
elif edited:
# use .jpeg as that's probably correct
# if edited and path_edited is None, will raise FileNotFoundError below
# unless use_photos_export is True
actual_suffix = ".jpeg"
else:
# use suffix from the non-edited file
actual_suffix = pathlib.Path(self.filename).suffix
# warn if suffixes don't match but ignore .JPG / .jpeg as
# Photo's often converts .JPG to .jpeg
suffixes = sorted([x.lower() for x in [dest.suffix, actual_suffix]])
if dest.suffix.lower() != actual_suffix.lower() and suffixes != [".jpeg", ".jpg"]:
logging.warning(
f"Invalid destination suffix: {dest.suffix}, should be {actual_suffix}"
)
# check to see if file exists and if so, add (1), (2), etc until we find one that works
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
# e.g. exporting sidecar for file1.png and file1.jpeg
@@ -279,10 +437,6 @@ def export2(
count += 1
dest = dest.parent / f"{dest_new}{dest.suffix}"
# TODO: need way to check if DB is missing, try to find the right photo anyway by seeing if they're the same and then updating
# move the checks into "if not use_photos_export" block below
# if use_photos_export is True then we'll export wether destination exists or not
# if overwrite==False and #increment==False, export should fail if file exists
if dest.exists() and not update and not overwrite and not increment:
raise FileExistsError(
@@ -314,6 +468,13 @@ def export2(
if not os.path.isfile(src):
raise FileNotFoundError(f"{src} does not appear to exist")
if not _check_export_suffix(src, dest, edited):
logging.warning(
f"Invalid destination suffix: {dest.suffix} for {self.path}, "
+ f"edited={edited}, path_edited={self.path_edited}, "
+ f"original_filename={self.original_filename}, filename={self.filename}"
)
logging.debug(
f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}"
)
@@ -328,11 +489,14 @@ def export2(
f"Found matching file with blank uuid: {self.uuid}, {dest}"
)
dest_uuid = self.uuid
export_db.set_uuid_for_file(dest, self.uuid)
export_db.set_info_for_uuid(self.uuid, self.json())
export_db.set_stat_orig_for_file(dest, file_sig(dest))
export_db.set_stat_exif_for_file(dest, (None, None, None))
export_db.set_exifdata_for_file(dest, None)
export_db.set_data(
dest,
self.uuid,
fileutil.file_sig(dest),
(None, None, None),
self.json(),
None,
)
if dest_uuid != self.uuid:
# not the right file, find the right one
logging.debug(
@@ -358,11 +522,14 @@ def export2(
)
dest = pathlib.Path(file_)
found_match = True
export_db.set_uuid_for_file(file_, self.uuid)
export_db.set_info_for_uuid(self.uuid, self.json())
export_db.set_stat_orig_for_file(dest, file_sig(dest))
export_db.set_stat_exif_for_file(dest, (None, None, None))
export_db.set_exifdata_for_file(dest, None)
export_db.set_data(
dest,
self.uuid,
fileutil.file_sig(dest),
(None, None, None),
self.json(),
None,
)
break
if not found_match:
@@ -392,6 +559,7 @@ def export2(
no_xattr,
export_as_hardlink,
exiftool,
fileutil,
)
exported_files = results.exported
update_new_files = results.new
@@ -416,6 +584,7 @@ def export2(
no_xattr,
export_as_hardlink,
exiftool,
fileutil,
)
exported_files.extend(results.exported)
update_new_files.extend(results.new)
@@ -440,6 +609,7 @@ def export2(
no_xattr,
export_as_hardlink,
exiftool,
fileutil,
)
exported_files.extend(results.exported)
update_new_files.extend(results.new)
@@ -471,6 +641,7 @@ def export2(
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
else:
# export original version and not edited
@@ -484,6 +655,7 @@ def export2(
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
if exported is not None:
@@ -503,12 +675,14 @@ def export2(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
raise e
if not dry_run:
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
raise e
if sidecar_xmp:
logging.debug("writing xmp_sidecar")
@@ -517,12 +691,14 @@ def export2(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
raise e
if not dry_run:
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
raise e
# if exiftool, write the metadata
if update:
@@ -543,6 +719,7 @@ def export2(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
)[0]
if old_data != current_data:
@@ -552,40 +729,50 @@ def export2(
# didn't have old data, assume we need to write it
# or files were different
logging.debug(f"No exifdata for {exported_file}, writing it")
self._write_exif_data(
exported_file,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
)
if not dry_run:
self._write_exif_data(
exported_file,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
export_db.set_exifdata_for_file(
exported_file,
self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
),
)
export_db.set_stat_exif_for_file(exported_file, file_sig(exported_file))
export_db.set_stat_exif_for_file(
exported_file, fileutil.file_sig(exported_file)
)
exif_files_updated.append(exported_file)
elif exiftool and exif_files:
for exported_file in exif_files:
logging.debug(f"Writing exif data to {exported_file}")
self._write_exif_data(
exported_file,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
)
if not dry_run:
self._write_exif_data(
exported_file,
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
export_db.set_exifdata_for_file(
exported_file,
self._exiftool_json_sidecar(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
),
)
export_db.set_stat_exif_for_file(exported_file, file_sig(exported_file))
export_db.set_stat_exif_for_file(
exported_file, fileutil.file_sig(exported_file)
)
exif_files_updated.append(exported_file)
return ExportResults(
@@ -607,12 +794,15 @@ def _export_photo(
no_xattr,
export_as_hardlink,
exiftool,
fileutil=FileUtil,
):
""" Helper function for export()
Does the actual copy or hardlink taking the appropriate
action depending on update, overwrite
Assumes destination is the right destination (e.g. UUID matches)
sets UUID and JSON info foo exported file using set_uuid_for_file, set_inf_for_uuido
Args:
src: src path (string)
dest: dest path (pathlib.Path)
update: bool
@@ -621,7 +811,10 @@ def _export_photo(
no_xattr: don't copy extended attributes
export_as_hardlink: bool
exiftool: bool
Returns: ExportResults
fileutil: FileUtil class that conforms to fileutil.FileUtilABC
Returns:
ExportResults
"""
exported_files = []
@@ -637,14 +830,18 @@ def _export_photo(
# not update, do the the hardlink
if overwrite and dest.exists():
# need to remove the destination first
dest.unlink()
# dest.unlink()
fileutil.unlink(dest)
logging.debug(f"Not update: export_as_hardlink linking file {src} {dest}")
_hardlink_file(src, dest)
export_db.set_uuid_for_file(dest_str, self.uuid)
export_db.set_info_for_uuid(self.uuid, self.json())
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
export_db.set_exifdata_for_file(dest_str, None)
fileutil.hardlink(src, dest)
export_db.set_data(
dest_str,
self.uuid,
fileutil.file_sig(dest_str),
(None, None, None),
self.json(),
None,
)
exported_files.append(dest_str)
elif dest_exists and dest.samefile(src):
# update, hardlink and it already points to the right file, do nothing
@@ -657,13 +854,17 @@ def _export_photo(
logging.debug(
f"Update: removing existing file prior to export_as_hardlink {src} {dest}"
)
dest.unlink()
_hardlink_file(src, dest)
export_db.set_uuid_for_file(dest_str, self.uuid)
export_db.set_info_for_uuid(self.uuid, self.json())
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
export_db.set_exifdata_for_file(dest_str, None)
# dest.unlink()
fileutil.unlink(dest)
fileutil.hardlink(src, dest)
export_db.set_data(
dest_str,
self.uuid,
fileutil.file_sig(dest_str),
(None, None, None),
self.json(),
None,
)
update_updated_files.append(dest_str)
exported_files.append(dest_str)
else:
@@ -671,12 +872,15 @@ def _export_photo(
logging.debug(
f"Update: exporting new file with export_as_hardlink {src} {dest}"
)
_hardlink_file(src, dest)
export_db.set_uuid_for_file(dest_str, self.uuid)
export_db.set_info_for_uuid(self.uuid, self.json())
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
export_db.set_exifdata_for_file(dest_str, None)
fileutil.hardlink(src, dest)
export_db.set_data(
dest_str,
self.uuid,
fileutil.file_sig(dest_str),
(None, None, None),
self.json(),
None,
)
exported_files.append(dest_str)
update_new_files.append(dest_str)
else:
@@ -684,15 +888,19 @@ def _export_photo(
# not update, do the the copy
if overwrite and dest.exists():
# need to remove the destination first
dest.unlink()
# dest.unlink()
fileutil.unlink(dest)
logging.debug(f"Not update: copying file {src} {dest}")
_copy_file(src, dest_str, norsrc=no_xattr)
export_db.set_uuid_for_file(dest_str, self.uuid)
export_db.set_info_for_uuid(self.uuid, self.json())
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
export_db.set_exifdata_for_file(dest_str, None)
fileutil.copy(src, dest_str, norsrc=no_xattr)
exported_files.append(dest_str)
export_db.set_data(
dest_str,
self.uuid,
fileutil.file_sig(dest_str),
(None, None, None),
self.json(),
None,
)
# elif dest_exists and not exiftool and cmp_file(dest_str, export_db.get_stat_orig_for_file(dest_str)):
elif (
dest_exists
@@ -704,12 +912,12 @@ def _export_photo(
logging.debug(f"Update: skipping identifical original files {src} {dest}")
# call set_stat because code can reach this spot if no export DB but exporting a RAW or live photo
# potentially re-writes the data in the database but ensures database is complete
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
export_db.set_stat_orig_for_file(dest_str, fileutil.file_sig(dest_str))
update_skipped_files.append(dest_str)
elif (
dest_exists
and exiftool
and cmp_file(dest_str, export_db.get_stat_exif_for_file(dest_str))
and fileutil.cmp_sig(dest_str, export_db.get_stat_exif_for_file(dest_str))
and not dest.samefile(src)
):
# destination exists but is identical
@@ -720,24 +928,31 @@ def _export_photo(
logging.debug(f"Update: removing existing file prior to copy {src} {dest}")
stat_src = os.stat(src)
stat_dest = os.stat(dest)
dest.unlink()
_copy_file(src, dest_str, norsrc=no_xattr)
export_db.set_uuid_for_file(dest_str, self.uuid)
export_db.set_info_for_uuid(self.uuid, self.json())
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
export_db.set_exifdata_for_file(dest_str, None)
# dest.unlink()
fileutil.unlink(dest)
fileutil.copy(src, dest_str, norsrc=no_xattr)
export_db.set_data(
dest_str,
self.uuid,
fileutil.file_sig(dest_str),
(None, None, None),
self.json(),
None,
)
exported_files.append(dest_str)
update_updated_files.append(dest_str)
else:
# destination doesn't exist, copy the file
logging.debug(f"Update: copying new file {src} {dest}")
_copy_file(src, dest_str, norsrc=no_xattr)
export_db.set_uuid_for_file(dest_str, self.uuid)
export_db.set_info_for_uuid(self.uuid, self.json())
export_db.set_stat_orig_for_file(dest_str, file_sig(dest_str))
export_db.set_stat_exif_for_file(dest_str, (None, None, None))
export_db.set_exifdata_for_file(dest_str, None)
fileutil.copy(src, dest_str, norsrc=no_xattr)
export_db.set_data(
dest_str,
self.uuid,
fileutil.file_sig(dest_str),
(None, None, None),
self.json(),
None,
)
exported_files.append(dest_str)
update_new_files.append(dest_str)
@@ -752,6 +967,7 @@ def _write_exif_data(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" write exif data to image file at filepath
filepath: full path to the image file """
@@ -763,6 +979,7 @@ def _write_exif_data(
use_albums_as_keywords=use_albums_as_keywords,
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
)
)[0]
for exiftag, val in exif_info.items():
@@ -781,6 +998,7 @@ def _exiftool_json_sidecar(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" return json string of EXIF details in exiftool sidecar format
Does not include all the EXIF fields as those are likely already in the image
@@ -806,7 +1024,13 @@ def _exiftool_json_sidecar(
exif = {}
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
if self.description:
if description_template is not None:
description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
exif["EXIF:ImageDescription"] = description
exif["XMP:Description"] = description
elif self.description:
exif["EXIF:ImageDescription"] = self.description
exif["XMP:Description"] = self.description
@@ -909,16 +1133,25 @@ def _xmp_sidecar(
use_albums_as_keywords=False,
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
):
""" returns string for XMP sidecar
use_albums_as_keywords: treat album names as keywords
use_persons_as_keywords: treat person names as keywords
keyword_template: (list of strings); list of template strings to render as keywords """
keyword_template: (list of strings); list of template strings to render as keywords
description_template: string; optional template string that will be rendered for use as photo description """
# TODO: add additional fields to XMP file?
xmp_template = Template(filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME))
if description_template is not None:
description = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
else:
description = self.description if self.description is not None else ""
keyword_list = []
if self.keywords:
keyword_list.extend(self.keywords)
@@ -975,7 +1208,11 @@ def _xmp_sidecar(
subject_list = list(self.keywords) + person_list
xmp_str = xmp_template.render(
photo=self, keywords=keyword_list, persons=person_list, subjects=subject_list
photo=self,
description=description,
keywords=keyword_list,
persons=person_list,
subjects=subject_list,
)
# remove extra lines that mako inserts from template
@@ -986,7 +1223,7 @@ def _xmp_sidecar(
def _write_sidecar(self, filename, sidecar_str):
""" write sidecar_str to filename
used for exporting sidecar info """
if not filename and not sidecar_str:
if not (filename or sidecar_str):
raise (
ValueError(
f"filename {filename} and sidecar_str {sidecar_str} must not be None"

View File

@@ -0,0 +1,119 @@
""" PhotoInfo methods to expose computed score info from the library """
import logging
from dataclasses import dataclass
from .._constants import _PHOTOS_4_VERSION
@dataclass(frozen=True)
class ScoreInfo:
""" Computed photo score info associated with a photo from the Photos library """
overall: float
curation: float
promotion: float
highlight_visibility: float
behavioral: float
failure: float
harmonious_color: float
immersiveness: float
interaction: float
interesting_subject: float
intrusive_object_presence: float
lively_color: float
low_light: float
noise: float
pleasant_camera_tilt: float
pleasant_composition: float
pleasant_lighting: float
pleasant_pattern: float
pleasant_perspective: float
pleasant_post_processing: float
pleasant_reflection: float
pleasant_symmetry: float
sharply_focused_subject: float
tastefully_blurred: float
well_chosen_subject: float
well_framed_subject: float
well_timed_shot: float
@property
def score(self):
""" Computed score information for a photo
Returns:
ScoreInfo instance
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
logging.debug(f"score not implemented for this database version")
return None
try:
return self._scoreinfo # pylint: disable=access-member-before-definition
except AttributeError:
try:
scores = self._db._db_scoreinfo_uuid[self.uuid]
self._scoreinfo = ScoreInfo(
overall=scores["overall_aesthetic"],
curation=scores["curation"],
promotion=scores["promotion"],
highlight_visibility=scores["highlight_visibility"],
behavioral=scores["behavioral"],
failure=scores["failure"],
harmonious_color=scores["harmonious_color"],
immersiveness=scores["immersiveness"],
interaction=scores["interaction"],
interesting_subject=scores["interesting_subject"],
intrusive_object_presence=scores["intrusive_object_presence"],
lively_color=scores["lively_color"],
low_light=scores["low_light"],
noise=scores["noise"],
pleasant_camera_tilt=scores["pleasant_camera_tilt"],
pleasant_composition=scores["pleasant_composition"],
pleasant_lighting=scores["pleasant_lighting"],
pleasant_pattern=scores["pleasant_pattern"],
pleasant_perspective=scores["pleasant_perspective"],
pleasant_post_processing=scores["pleasant_post_processing"],
pleasant_reflection=scores["pleasant_reflection"],
pleasant_symmetry=scores["pleasant_symmetry"],
sharply_focused_subject=scores["sharply_focused_subject"],
tastefully_blurred=scores["tastefully_blurred"],
well_chosen_subject=scores["well_chosen_subject"],
well_framed_subject=scores["well_framed_subject"],
well_timed_shot=scores["well_timed_shot"],
)
return self._scoreinfo
except KeyError:
self._scoreinfo = ScoreInfo(
overall=0.0,
curation=0.0,
promotion=0.0,
highlight_visibility=0.0,
behavioral=0.0,
failure=0.0,
harmonious_color=0.0,
immersiveness=0.0,
interaction=0.0,
interesting_subject=0.0,
intrusive_object_presence=0.0,
lively_color=0.0,
low_light=0.0,
noise=0.0,
pleasant_camera_tilt=0.0,
pleasant_composition=0.0,
pleasant_lighting=0.0,
pleasant_pattern=0.0,
pleasant_perspective=0.0,
pleasant_post_processing=0.0,
pleasant_reflection=0.0,
pleasant_symmetry=0.0,
sharply_focused_subject=0.0,
tastefully_blurred=0.0,
well_chosen_subject=0.0,
well_framed_subject=0.0,
well_timed_shot=0.0,
)
return self._scoreinfo

View File

@@ -11,7 +11,6 @@ import logging
import os
import os.path
import pathlib
import re
import subprocess
import sys
from datetime import timedelta, timezone
@@ -22,19 +21,17 @@ import yaml
from .._constants import (
_MOVIE_TYPE,
_PHOTO_TYPE,
_PHOTOS_4_ALBUM_KIND,
_PHOTOS_4_ROOT_FOLDER,
_PHOTOS_4_VERSION,
_PHOTOS_5_ALBUM_KIND,
_PHOTOS_5_SHARED_ALBUM_KIND,
_PHOTOS_5_SHARED_PHOTO_PATH,
_UNKNOWN_PERSON,
)
from ..albuminfo import AlbumInfo
from ..datetime_formatter import DateTimeFormatter
from ..phototemplate import PhotoTemplate
from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
from .template import (
MULTI_VALUE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS,
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
)
class PhotoInfo:
@@ -62,6 +59,7 @@ class PhotoInfo:
_xmp_sidecar,
ExportResults,
)
from ._photoinfo_scoreinfo import score, ScoreInfo
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
@@ -71,6 +69,7 @@ class PhotoInfo:
@property
def filename(self):
""" filename of the picture """
# sourcery off
if self.has_raw and self.raw_original:
# return name of the RAW file
# TODO: not yet implemented
@@ -91,8 +90,7 @@ class PhotoInfo:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
return imagedate.astimezone(tz=tz)
@property
def date_modified(self):
@@ -103,8 +101,7 @@ class PhotoInfo:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
imagedate_utc = imagedate.astimezone(tz=tz)
return imagedate_utc
return imagedate.astimezone(tz=tz)
else:
return None
@@ -347,21 +344,26 @@ class PhotoInfo:
@property
def albums(self):
""" list of albums picture is contained in """
albums = []
for album in self._info["albums"]:
if not self._db._dbalbum_details[album]["intrash"]:
albums.append(self._db._dbalbum_details[album]["title"])
return albums
try:
return self._albums
except AttributeError:
album_uuids = self._get_album_uuids()
self._albums = list(
{self._db._dbalbum_details[album]["title"] for album in album_uuids}
)
return self._albums
@property
def album_info(self):
""" list of AlbumInfo objects representing albums the photos is contained in """
albums = []
for album in self._info["albums"]:
if not self._db._dbalbum_details[album]["intrash"]:
albums.append(AlbumInfo(db=self._db, uuid=album))
return albums
try:
return self._album_info
except AttributeError:
album_uuids = self._get_album_uuids()
self._album_info = [
AlbumInfo(db=self._db, uuid=album) for album in album_uuids
]
return self._album_info
@property
def keywords(self):
@@ -415,6 +417,11 @@ class PhotoInfo:
""" True if picture is hidden """
return True if self._info["hidden"] == 1 else False
@property
def intrash(self):
""" True if picture is in trash ('Recently Deleted' folder)"""
return self._info["intrash"]
@property
def location(self):
""" returns (latitude, longitude) as float in degrees or None """
@@ -491,12 +498,11 @@ class PhotoInfo:
self is not included in the returned list """
if self._info["burst"]:
burst_uuid = self._info["burstUUID"]
burst_photos = [
return [
PhotoInfo(db=self._db, uuid=u, info=self._db._dbphotos[u])
for u in self._db._dbphotos_burst[burst_uuid]
if u != self._uuid
]
return burst_photos
else:
return []
@@ -636,339 +642,37 @@ class PhotoInfo:
otherwise returns False """
return self._info["raw_is_original"]
def render_template(self, template, none_str="_", path_sep=None):
""" render a filename or directory template
template: str template
none_str: str to use default for None values, default is '_'
path_sep: optional character to use as path separator, default is os.path.sep """
def render_template(
self,
template_str,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
):
"""Renders a template string for PhotoInfo instance using PhotoTemplate
if path_sep is None:
path_sep = os.path.sep
elif path_sep is not None and len(path_sep) != 1:
raise ValueError(f"path_sep must be single character: {path_sep}")
# 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)}")
def make_subst_function(self, none_str, get_func=self.get_template_value):
""" returns: substitution function for use in re.sub
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
# closure to capture photo, none_str in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 4:
try:
val = get_func(matchobj.group(1))
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(self, 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 = self.albums
elif field == "keyword":
values = self.keywords
elif field == "person":
values = self.persons
# remove any _UNKNOWN_PERSON values
values = [val for val in values if val != _UNKNOWN_PERSON]
elif field == "label":
values = self.labels
elif field == "label_normalized":
values = self.labels_normalized
elif field == "folder_album":
values = []
# photos must be in an album to be in a folder
for album in self.album_info:
if album.folder_names:
# album in folder
folder = path_sep.join(album.folder_names)
folder += 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")(,(([\w\-. ]{0,})))?\}"
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):
""" 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(
self, 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
def get_template_value(self, lookup):
""" lookup template value (single-value template substitutions) for use in make_subst_function
lookup: value to find a match for
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(self.filename).stem
if lookup == "original_name":
return pathlib.Path(self.original_filename).stem
if lookup == "title":
return self.title
if lookup == "descr":
return self.description
if lookup == "created.date":
return DateTimeFormatter(self.date).date
if lookup == "created.year":
return DateTimeFormatter(self.date).year
if lookup == "created.yy":
return DateTimeFormatter(self.date).yy
if lookup == "created.mm":
return DateTimeFormatter(self.date).mm
if lookup == "created.month":
return DateTimeFormatter(self.date).month
if lookup == "created.mon":
return DateTimeFormatter(self.date).mon
if lookup == "created.doy":
return DateTimeFormatter(self.date).doy
if lookup == "modified.date":
return (
DateTimeFormatter(self.date_modified).date
if self.date_modified
else None
)
if lookup == "modified.year":
return (
DateTimeFormatter(self.date_modified).year
if self.date_modified
else None
)
if lookup == "modified.yy":
return (
DateTimeFormatter(self.date_modified).yy if self.date_modified else None
)
if lookup == "modified.mm":
return (
DateTimeFormatter(self.date_modified).mm if self.date_modified else None
)
if lookup == "modified.month":
return (
DateTimeFormatter(self.date_modified).month
if self.date_modified
else None
)
if lookup == "modified.mon":
return (
DateTimeFormatter(self.date_modified).mon
if self.date_modified
else None
)
if lookup == "modified.doy":
return (
DateTimeFormatter(self.date_modified).doy
if self.date_modified
else None
)
if lookup == "place.name":
return self.place.name if self.place else None
if lookup == "place.country_code":
return self.place.country_code if self.place else None
if lookup == "place.name.country":
return (
self.place.names.country[0]
if self.place and self.place.names.country
else None
)
if lookup == "place.name.state_province":
return (
self.place.names.state_province[0]
if self.place and self.place.names.state_province
else None
)
if lookup == "place.name.city":
return (
self.place.names.city[0]
if self.place and self.place.names.city
else None
)
if lookup == "place.name.area_of_interest":
return (
self.place.names.area_of_interest[0]
if self.place and self.place.names.area_of_interest
else None
)
if lookup == "place.address":
return (
self.place.address_str
if self.place and self.place.address_str
else None
)
if lookup == "place.address.street":
return (
self.place.address.street
if self.place and self.place.address.street
else None
)
if lookup == "place.address.city":
return (
self.place.address.city
if self.place and self.place.address.city
else None
)
if lookup == "place.address.state_province":
return (
self.place.address.state_province
if self.place and self.place.address.state_province
else None
)
if lookup == "place.address.postal_code":
return (
self.place.address.postal_code
if self.place and self.place.address.postal_code
else None
)
if lookup == "place.address.country":
return (
self.place.address.country
if self.place and self.place.address.country
else None
)
if lookup == "place.address.country_code":
return (
self.place.address.iso_country_code
if self.place and self.place.address.iso_country_code
else None
)
# if here, didn't get a match
raise KeyError(f"No rule for processing {lookup}")
Args:
template_str: a template string with fields to render
none_str: a str to use if template field renders to None, default is "_".
path_sep: a single character str to use as path separator when joining
fields like folder_album; if not provided, defaults to os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
template = PhotoTemplate(self)
return template.render(
template_str,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
)
@property
def _longitude(self):
@@ -980,6 +684,37 @@ class PhotoInfo:
""" Returns latitude, in degrees """
return self._info["latitude"]
def _get_album_uuids(self):
""" Return list of album UUIDs this photo is found in
Filters out albums in the trash and any special album types
Returns: list of album UUIDs
"""
if self._db._db_version <= _PHOTOS_4_VERSION:
version4 = True
album_kind = [_PHOTOS_4_ALBUM_KIND]
else:
version4 = False
album_kind = [_PHOTOS_5_SHARED_ALBUM_KIND, _PHOTOS_5_ALBUM_KIND]
album_list = []
for album in self._info["albums"]:
detail = self._db._dbalbum_details[album]
if (
detail["kind"] in album_kind
and not detail["intrash"]
and (
not version4
# in Photos <= 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
# but should not be listed here; they can be distinguished by looking
# for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
or (version4 and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER)
)
):
album_list.append(album)
return album_list
def __repr__(self):
return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})"
@@ -990,6 +725,8 @@ class PhotoInfo:
date_modified_iso = (
self.date_modified.isoformat() if self.date_modified else None
)
exif = str(self.exif_info) if self.exif_info else None
score = str(self.score) if self.score else None
info = {
"uuid": self.uuid,
@@ -1030,6 +767,10 @@ class PhotoInfo:
"has_raw": self.has_raw,
"uti_raw": self.uti_raw,
"path_raw": self.path_raw,
"place": self.place,
"exif": exif,
"score": score,
"intrash": self.intrash,
}
return yaml.dump(info, sort_keys=False)
@@ -1042,6 +783,7 @@ class PhotoInfo:
folders = {album.title: album.folder_names for album in self.album_info}
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
place = self.place.as_dict() if self.place else {}
score = dataclasses.asdict(self.score) if self.score else {}
pic = {
"uuid": self.uuid,
@@ -1087,15 +829,23 @@ class PhotoInfo:
"path_raw": self.path_raw,
"place": place,
"exif": exif,
"score": score,
"intrash": self.intrash,
}
return json.dumps(pic)
# compare two PhotoInfo objects for equality
def __eq__(self, other):
""" Compare two PhotoInfo objects for equality """
# Can't just compare the two __dicts__ because some methods (like albums)
# memoize their value once called in an instance variable (e.g. self._albums)
if isinstance(other, self.__class__):
return self.__dict__ == other.__dict__
return (
self._db.db_path == other._db.db_path
and self.uuid == other.uuid
and self._info == other._info
)
return False
def __ne__(self, other):
""" Compare two PhotoInfo objects for inequality """
return not self.__eq__(other)

View File

@@ -1,66 +0,0 @@
""" Custom template system for osxphotos (implemented in PhotoInfo.render_template) """
# 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 locale
# ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "")
# Permitted substitutions (each of these returns a single value or None)
TEMPLATE_SUBSTITUTIONS = {
"{name}": "Current 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",
"{label}": "Image categorization label associated with a photo (Photos 5 only)",
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
}
# Just the multi-valued substitution names without the braces
MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys()
]

View File

@@ -0,0 +1,145 @@
""" Methods for PhotosDB to add Photos 5 photo score info
ref: https://simonwillison.net/2020/May/21/dogsheep-photos/
"""
import logging
from .._constants import _PHOTOS_4_VERSION
from ..utils import _open_sql_file
"""
This module should be imported in the class defintion of PhotosDB in photosdb.py
Do not import this module directly
This module adds the following method to PhotosDB:
_process_scoreinfo: process photo score info
The following data structures are added to PhotosDB
self._db_scoreinfo_uuid
These methods only work on Photos 5 databases. Will print warning on earlier library versions.
"""
def _process_scoreinfo(self):
""" Process computed photo scores
Note: Only works on Photos version == 5.0
"""
# _db_scoreinfo_uuid is dict in form {uuid: {score values}}
self._db_scoreinfo_uuid = {}
if self._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
f"search info not implemented for this database version"
)
else:
_process_scoreinfo_5(self)
def _process_scoreinfo_5(photosdb):
""" Process computed photo scores for Photos 5 databases
Args:
photosdb: an OSXPhotosDB instance
"""
db = photosdb._tmp_db
(conn, cursor) = _open_sql_file(db)
result = cursor.execute(
"""
SELECT
ZGENERICASSET.ZUUID,
ZGENERICASSET.ZOVERALLAESTHETICSCORE,
ZGENERICASSET.ZCURATIONSCORE,
ZGENERICASSET.ZPROMOTIONSCORE,
ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZIMMERSIVENESSSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZINTERACTIONSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZINTERESTINGSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZINTRUSIVEOBJECTPRESENCESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZLIVELYCOLORSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZLOWLIGHT,
ZCOMPUTEDASSETATTRIBUTES.ZNOISESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCAMERATILTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCOMPOSITIONSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTLIGHTINGSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPATTERNSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPERSPECTIVESCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPOSTPROCESSINGSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTREFLECTIONSSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTSYMMETRYSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZSHARPLYFOCUSEDSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZTASTEFULLYBLURREDSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE,
ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE
FROM ZGENERICASSET
JOIN ZCOMPUTEDASSETATTRIBUTES ON ZCOMPUTEDASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
"""
)
# 0 ZGENERICASSET.ZUUID,
# 1 ZGENERICASSET.ZOVERALLAESTHETICSCORE,
# 2 ZGENERICASSET.ZCURATIONSCORE,
# 3 ZGENERICASSET.ZPROMOTIONSCORE,
# 4 ZGENERICASSET.ZHIGHLIGHTVISIBILITYSCORE,
# 5 ZCOMPUTEDASSETATTRIBUTES.ZBEHAVIORALSCORE,
# 6 ZCOMPUTEDASSETATTRIBUTES.ZFAILURESCORE,
# 7 ZCOMPUTEDASSETATTRIBUTES.ZHARMONIOUSCOLORSCORE,
# 8 ZCOMPUTEDASSETATTRIBUTES.ZIMMERSIVENESSSCORE,
# 9 ZCOMPUTEDASSETATTRIBUTES.ZINTERACTIONSCORE,
# 10 ZCOMPUTEDASSETATTRIBUTES.ZINTERESTINGSUBJECTSCORE,
# 11 ZCOMPUTEDASSETATTRIBUTES.ZINTRUSIVEOBJECTPRESENCESCORE,
# 12 ZCOMPUTEDASSETATTRIBUTES.ZLIVELYCOLORSCORE,
# 13 ZCOMPUTEDASSETATTRIBUTES.ZLOWLIGHT,
# 14 ZCOMPUTEDASSETATTRIBUTES.ZNOISESCORE,
# 15 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCAMERATILTSCORE,
# 16 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTCOMPOSITIONSCORE,
# 17 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTLIGHTINGSCORE,
# 18 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPATTERNSCORE,
# 19 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPERSPECTIVESCORE,
# 20 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTPOSTPROCESSINGSCORE,
# 21 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTREFLECTIONSSCORE,
# 22 ZCOMPUTEDASSETATTRIBUTES.ZPLEASANTSYMMETRYSCORE,
# 23 ZCOMPUTEDASSETATTRIBUTES.ZSHARPLYFOCUSEDSUBJECTSCORE,
# 24 ZCOMPUTEDASSETATTRIBUTES.ZTASTEFULLYBLURREDSCORE,
# 25 ZCOMPUTEDASSETATTRIBUTES.ZWELLCHOSENSUBJECTSCORE,
# 26 ZCOMPUTEDASSETATTRIBUTES.ZWELLFRAMEDSUBJECTSCORE,
# 27 ZCOMPUTEDASSETATTRIBUTES.ZWELLTIMEDSHOTSCORE
for row in result:
uuid = row[0]
scores = {"uuid": uuid}
scores["overall_aesthetic"] = row[1]
scores["curation"] = row[2]
scores["promotion"] = row[3]
scores["highlight_visibility"] = row[4]
scores["behavioral"] = row[5]
scores["failure"] = row[6]
scores["harmonious_color"] = row[7]
scores["immersiveness"] = row[8]
scores["interaction"] = row[9]
scores["interesting_subject"] = row[10]
scores["intrusive_object_presence"] = row[11]
scores["lively_color"] = row[12]
scores["low_light"] = row[13]
scores["noise"] = row[14]
scores["pleasant_camera_tilt"] = row[15]
scores["pleasant_composition"] = row[16]
scores["pleasant_lighting"] = row[17]
scores["pleasant_pattern"] = row[18]
scores["pleasant_perspective"] = row[19]
scores["pleasant_post_processing"] = row[20]
scores["pleasant_reflection"] = row[21]
scores["pleasant_symmetry"] = row[22]
scores["sharply_focused_subject"] = row[23]
scores["tastefully_blurred"] = row[24]
scores["well_chosen_subject"] = row[25]
scores["well_framed_subject"] = row[26]
scores["well_timed_shot"] = row[27]
photosdb._db_scoreinfo_uuid[uuid] = scores

View File

@@ -3,6 +3,7 @@
ref: https://github.com/dogsheep/photos-to-sqlite/issues/16
"""
from functools import lru_cache
import logging
import pathlib
import uuid as uuidlib
@@ -39,6 +40,19 @@ def _process_searchinfo(self):
search terms. In Photos 5, this is called psi.sqlite
Note: Only works on Photos version == 5.0 """
# _db_searchinfo_uuid is dict in form {uuid : [list of associated search info records]
self._db_searchinfo_uuid = _db_searchinfo_uuid = {}
# _db_searchinfo_categories is dict in form {search info category id: list normalized strings for the category
# right now, this is mostly for debugging to easily see which search terms are in the library
self._db_searchinfo_categories = _db_searchinfo_categories = {}
# _db_searchinfo_labels is dict in form {normalized label: [list of photo uuids]}
# this serves as a reverse index from label to photos containing the label
# _db_searchinfo_labels_normalized is the same but with normalized (lower case) version of the label
self._db_searchinfo_labels = _db_searchinfo_labels = {}
self._db_searchinfo_labels_normalized = _db_searchinfo_labels_normalized = {}
if self._db_version <= _PHOTOS_4_VERSION:
raise NotImplementedError(
f"search info not implemented for this database version"
@@ -46,7 +60,8 @@ def _process_searchinfo(self):
search_db_path = pathlib.Path(self._dbfile).parent / "search" / "psi.sqlite"
if not search_db_path.exists():
raise FileNotFoundError(f"could not find search db: {search_db_path}")
logging.warning(f"could not find search db: {search_db_path}")
return None
if _db_is_locked(search_db_path):
search_db = self._copy_db_file(search_db_path)
@@ -55,7 +70,7 @@ def _process_searchinfo(self):
(conn, c) = _open_sql_file(search_db)
result = conn.execute(
result = c.execute(
"""
select
ga.rowid,
@@ -76,27 +91,31 @@ def _process_searchinfo(self):
"""
)
# _db_searchinfo_uuid is dict in form {uuid : [list of associated search info records]
_db_searchinfo_uuid = {}
# 0: ga.rowid,
# 1: assets.uuid_0,
# 2: assets.uuid_1,
# 3: groups.rowid as groupid,
# 4: groups.category,
# 5: groups.owning_groupid,
# 6: groups.content_string,
# 7: groups.normalized_string,
# 8: groups.lookup_identifier
# _db_searchinfo_categories is dict in form {search info category id: list normalized strings for the category
# right now, this is mostly for debugging to easily see which search terms are in the library
_db_searchinfo_categories = {}
# _db_searchinfo_labels is dict in form {normalized label: [list of photo uuids]}
# this serves as a reverse index from label to photos containing the label
# _db_searchinfo_labels_normalized is the same but with normalized (lower case) version of the label
_db_searchinfo_labels = {}
_db_searchinfo_labels_normalized = {}
cols = [c[0] for c in result.description]
for row in result.fetchall():
record = dict(zip(cols, row))
uuid = ints_to_uuid(record["uuid_0"], record["uuid_1"])
for row in c:
uuid = ints_to_uuid(row[1], row[2])
# strings have null character appended, so strip it
for key in record:
if isinstance(record[key], str):
record[key] = record[key].replace("\x00", "")
record = {}
record["uuid"] = uuid
record["rowid"] = row[0]
record["uuid_0"] = row[1]
record["uuid_1"] = row[2]
record["groupid"] = row[3]
record["category"] = row[4]
record["owning_groupid"] = row[5]
record["content_string"] = row[6].replace("\x00", "")
record["normalized_string"] = row[7].replace("\x00", "")
record["lookup_identifier"] = row[8]
try:
_db_searchinfo_uuid[uuid].append(record)
except KeyError:
@@ -104,15 +123,11 @@ def _process_searchinfo(self):
category = record["category"]
try:
_db_searchinfo_categories[record["category"]].append(
record["normalized_string"]
)
_db_searchinfo_categories[category].append(record["normalized_string"])
except KeyError:
_db_searchinfo_categories[record["category"]] = [
record["normalized_string"]
]
_db_searchinfo_categories[category] = [record["normalized_string"]]
if record["category"] == SEARCH_CATEGORY_LABEL:
if category == SEARCH_CATEGORY_LABEL:
label = record["content_string"]
label_norm = record["normalized_string"]
try:
@@ -122,11 +137,6 @@ def _process_searchinfo(self):
_db_searchinfo_labels[label] = [uuid]
_db_searchinfo_labels_normalized[label_norm] = [uuid]
self._db_searchinfo_categories = _db_searchinfo_categories
self._db_searchinfo_uuid = _db_searchinfo_uuid
self._db_searchinfo_labels = _db_searchinfo_labels
self._db_searchinfo_labels_normalized = _db_searchinfo_labels_normalized
if _debug():
logging.debug(
"_db_searchinfo_categories: \n" + pformat(self._db_searchinfo_categories)
@@ -185,6 +195,7 @@ def labels_normalized_as_dict(self):
# The following method is not imported into PhotosDB
@lru_cache(maxsize=128)
def ints_to_uuid(uuid_0, uuid_1):
""" convert two signed ints into a UUID strings
uuid_0, uuid_1: the two int components of an RFC 4122 UUID """

View File

@@ -46,8 +46,6 @@ from ..utils import (
# 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__
# TODO: Add special albums and magic albums
@@ -64,6 +62,7 @@ class PhotosDB:
labels_as_dict,
labels_normalized_as_dict,
)
from ._photosdb_process_scoreinfo import _process_scoreinfo
def __init__(self, *dbfile_, dbfile=None):
""" create a new PhotosDB object
@@ -254,7 +253,7 @@ class PhotosDB:
if _db_is_locked(self._dbfile):
self._tmp_db = self._copy_db_file(self._dbfile)
self._db_version = self._get_db_version()
self._db_version = self._get_db_version(self._tmp_db)
# If Photos >= 5, actual data isn't in photos.db but in Photos.sqlite
if int(self._db_version) > int(_PHOTOS_4_VERSION):
@@ -294,18 +293,17 @@ class PhotosDB:
@property
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 = {
k: len(self._dbkeywords_keyword[k]) for k in self._dbkeywords_keyword.keys()
}
keywords = dict(sorted(keywords.items(), key=lambda kv: kv[1], reverse=True))
return keywords
@property
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 = {k: len(self._dbfaces_person[k]) for k in self._dbfaces_person.keys()}
persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
return persons
@@ -313,18 +311,16 @@ class PhotosDB:
def albums_as_dict(self):
""" return albums as dict of albums, count in reverse sorted order (descending) """
albums = {}
album_keys = [
k
for k in self._dbalbums_album.keys()
if self._dbalbum_details[k]["cloudownerhashedpersonid"] is None
and not self._dbalbum_details[k]["intrash"]
]
for k in album_keys:
title = self._dbalbum_details[k]["title"]
if title in albums:
albums[title] += len(self._dbalbums_album[k])
album_keys = self._get_album_uuids(shared=False)
for album in album_keys:
title = self._dbalbum_details[album]["title"]
if album in self._dbalbums_album:
try:
albums[title] += len(self._dbalbums_album[album])
except KeyError:
albums[title] = len(self._dbalbums_album[album])
else:
albums[title] = len(self._dbalbums_album[k])
albums[title] = 0 # empty album
albums = dict(sorted(albums.items(), key=lambda kv: kv[1], reverse=True))
return albums
@@ -333,25 +329,17 @@ class PhotosDB:
""" returns shared albums as dict of albums, count in reverse sorted order (descending)
valid only on Photos 5; on Photos <= 4, prints warning and returns empty dict """
# if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(
f"albums_shared not implemented for Photos versions < {_PHOTOS_5_VERSION}"
)
return {}
albums = {}
album_keys = [
k
for k in self._dbalbums_album.keys()
if self._dbalbum_details[k]["cloudownerhashedpersonid"] is not None
]
for k in album_keys:
title = self._dbalbum_details[k]["title"]
if title in albums:
albums[title] += len(self._dbalbums_album[k])
album_keys = self._get_album_uuids(shared=True)
for album in album_keys:
title = self._dbalbum_details[album]["title"]
if album in self._dbalbums_album:
try:
albums[title] += len(self._dbalbums_album[album])
except KeyError:
albums[title] = len(self._dbalbums_album[album])
else:
albums[title] = len(self._dbalbums_album[k])
albums[title] = 0 # empty album
albums = dict(sorted(albums.items(), key=lambda kv: kv[1], reverse=True))
return albums
@@ -412,34 +400,28 @@ class PhotosDB:
@property
def album_info(self):
""" return list of AlbumInfo objects for each album in the photos database """
albums = [
AlbumInfo(db=self, uuid=album)
for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is None
and not self._dbalbum_details[album]["intrash"]
]
return albums
try:
return self._album_info
except AttributeError:
self._album_info = [
AlbumInfo(db=self, uuid=album)
for album in self._get_album_uuids(shared=False)
]
return self._album_info
@property
def album_info_shared(self):
""" return list of AlbumInfo objects for each shared album in the photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
# if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(
f"albums_shared not implemented for Photos versions < {_PHOTOS_5_VERSION}"
)
return []
albums_shared = [
AlbumInfo(db=self, uuid=album)
for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is not None
and not self._dbalbum_details[album]["intrash"]
]
return albums_shared
try:
return self._album_info_shared
except AttributeError:
self._album_info_shared = [
AlbumInfo(db=self, uuid=album)
for album in self._get_album_uuids(shared=True)
]
return self._album_info_shared
@property
def albums(self):
@@ -448,13 +430,11 @@ class PhotosDB:
# Could be more than one album with same name
# Right now, they are treated as same album and photos are combined from albums with same name
albums = {
self._dbalbum_details[album]["title"]
for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is None
and not self._dbalbum_details[album]["intrash"]
}
return list(albums)
try:
return self._albums
except AttributeError:
self._albums = self._get_albums(shared=False)
return self._albums
@property
def albums_shared(self):
@@ -466,19 +446,11 @@ class PhotosDB:
# if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
if self._db_version <= _PHOTOS_4_VERSION:
logging.warning(
f"album_names_shared not implemented for Photos versions < {_PHOTOS_5_VERSION}"
)
return []
albums = {
self._dbalbum_details[album]["title"]
for album in self._dbalbums_album.keys()
if self._dbalbum_details[album]["cloudownerhashedpersonid"] is not None
and not self._dbalbum_details[album]["intrash"]
}
return list(albums)
try:
return self._albums_shared
except AttributeError:
self._albums_shared = self._get_albums(shared=True)
return self._albums_shared
@property
def db_version(self):
@@ -495,6 +467,14 @@ class PhotosDB:
""" returns path to the Photos library PhotosDB was initialized with """
return self._library_path
def get_db_connection(self):
""" Get connection to the working copy of the Photos database
Returns:
tuple of (connection, cursor) to sqlite3 database
"""
return _open_sql_file(self._tmp_db)
def _copy_db_file(self, fname):
""" copies the sqlite database file to a temp file """
""" returns the name of the temp file """
@@ -519,12 +499,18 @@ class PhotosDB:
return dest_path
def _get_db_version(self):
""" gets the Photos DB version from LiGlobals table """
""" returns the version as str"""
def _get_db_version(self, db_file):
""" Gets the Photos DB version from LiGlobals table
Args:
db_file: path to database file containing LiGlobals table
Returns: version as str
"""
version = None
(conn, c) = _open_sql_file(self._tmp_db)
(conn, c) = _open_sql_file(db_file)
# get database version
c.execute(
@@ -624,6 +610,8 @@ class PhotosDB:
"folderUuid": album[5],
"albumType": album[6],
"albumSubclass": album[7],
# for compatability with Photos 5 where album kind is ZKIND
"kind": album[7],
}
# get details about folders
@@ -727,9 +715,10 @@ class PhotosDB:
RKVersion.specialType, RKMaster.modelID, null, RKVersion.momentUuid,
RKVersion.rawMasterUuid,
RKVersion.nonRawMasterUuid,
RKMaster.alternateMasterUuid
FROM RKVersion, RKMaster WHERE RKVersion.isInTrash = 0 AND
RKVersion.masterUuid = RKMaster.uuid AND RKVersion.filename NOT LIKE '%.pdf' """
RKMaster.alternateMasterUuid,
RKVersion.isInTrash
FROM RKVersion, RKMaster
WHERE RKVersion.masterUuid = RKMaster.uuid"""
)
else:
c.execute(
@@ -746,9 +735,10 @@ class PhotosDB:
RKVersion.momentUuid,
RKVersion.rawMasterUuid,
RKVersion.nonRawMasterUuid,
RKMaster.alternateMasterUuid
FROM RKVersion, RKMaster WHERE RKVersion.isInTrash = 0 AND
RKVersion.masterUuid = RKMaster.uuid AND RKVersion.filename NOT LIKE '%.pdf' """
RKMaster.alternateMasterUuid,
RKVersion.isInTrash
FROM RKVersion, RKMaster
WHERE RKVersion.masterUuid = RKMaster.uuid"""
)
# order of results
@@ -784,6 +774,7 @@ class PhotosDB:
# 29 RKVersion.rawMasterUuid, -- UUID of RAW master
# 30 RKVersion.nonRawMasterUuid, -- UUID of non-RAW master
# 31 RKMaster.alternateMasterUuid -- UUID of alternate master (will be RAW master for JPEG and JPEG master for RAW)
# 32 RKVersion.isInTrash
for row in c:
uuid = row[0]
@@ -810,7 +801,7 @@ class PhotosDB:
try:
self._dbphotos[uuid]["imageDate"] = datetime.fromtimestamp(row[5] + td)
except ValueError:
self._dbphotos[uuid]["imageDate"] = datetime.date(1970, 1, 1)
self._dbphotos[uuid]["imageDate"] = datetime(1970, 1, 1)
self._dbphotos[uuid]["mainRating"] = row[6]
self._dbphotos[uuid]["hasAdjustments"] = row[7]
@@ -926,6 +917,9 @@ class PhotosDB:
self._dbphotos[uuid]["non_raw_master_uuid"] = row[30]
self._dbphotos[uuid]["alt_master_uuid"] = row[31]
# recently deleted items
self._dbphotos[uuid]["intrash"] = True if row[32] == 1 else False
# get additional details from RKMaster, needed for RAW processing
c.execute(
""" SELECT
@@ -976,8 +970,7 @@ class PhotosDB:
RKModelResource.resourceTag, RKModelResource.UTI, RKVersion.specialType,
RKModelResource.attachedModelType, RKModelResource.resourceType
FROM RKVersion
JOIN RKModelResource on RKModelResource.attachedModelId = RKVersion.modelId
WHERE RKVersion.isInTrash = 0 """
JOIN RKModelResource on RKModelResource.attachedModelId = RKVersion.modelId """
)
# Order of results:
@@ -1020,8 +1013,7 @@ class PhotosDB:
RKAdjustmentData.originator,
RKAdjustmentData.format
FROM RKVersion, RKAdjustmentData
WHERE RKVersion.adjustmentUuid = RKAdjustmentData.uuid
AND RKVersion.isInTrash = 0 """
WHERE RKVersion.adjustmentUuid = RKAdjustmentData.uuid """
)
for row in c:
@@ -1043,8 +1035,6 @@ class PhotosDB:
INNER JOIN RKMaster on RKVersion.masterUuid = RKMaster.uuid
INNER JOIN RKModelResource on RKMaster.modelId = RKModelResource.attachedModelId
WHERE RKModelResource.UTI = 'com.apple.quicktime-movie'
AND RKMaster.isInTrash = 0
AND RKVersion.isInTrash = 0
"""
)
@@ -1289,7 +1279,6 @@ class PhotosDB:
"SELECT ZPERSON.ZFULLNAME, ZGENERICASSET.ZUUID "
"FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET "
"WHERE ZDETECTEDFACE.ZPERSON = ZPERSON.Z_PK AND ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK "
"AND ZGENERICASSET.ZTRASHEDSTATE = 0"
)
for person in c:
if person[0] is None:
@@ -1313,7 +1302,6 @@ class PhotosDB:
"FROM ZGENERICASSET "
"JOIN Z_26ASSETS ON Z_26ASSETS.Z_34ASSETS = ZGENERICASSET.Z_PK "
"JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS "
"WHERE ZGENERICASSET.ZTRASHEDSTATE = 0 "
)
for album in c:
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
@@ -1415,7 +1403,6 @@ class PhotosDB:
"JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK "
"JOIN Z_1KEYWORDS ON Z_1KEYWORDS.Z_1ASSETATTRIBUTES = ZADDITIONALASSETATTRIBUTES.Z_PK "
"JOIN ZKEYWORD ON ZKEYWORD.Z_PK = Z_1KEYWORDS.Z_37KEYWORDS "
"WHERE ZGENERICASSET.ZTRASHEDSTATE = 0 "
)
for keyword in c:
if not keyword[1] in self._dbkeywords_uuid:
@@ -1469,10 +1456,10 @@ class PhotosDB:
ZGENERICASSET.ZCLOUDASSETGUID,
ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA,
ZGENERICASSET.ZMOMENT,
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE,
ZGENERICASSET.ZTRASHEDSTATE
FROM ZGENERICASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
WHERE ZGENERICASSET.ZTRASHEDSTATE = 0
ORDER BY ZGENERICASSET.ZUUID """
)
# Order of results
@@ -1505,6 +1492,7 @@ class PhotosDB:
# 25 ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA -- reverse geolocation data
# 26 ZGENERICASSET.ZMOMENT -- FK for ZMOMENT.Z_PK
# 27 ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE -- 1 if associated RAW image is original else 0
# 28 ZGENERICASSET.ZTRASHEDSTATE -- 0 if not in trash, 1 if in trash
for row in c:
uuid = row[0]
@@ -1528,7 +1516,7 @@ class PhotosDB:
try:
info["imageDate"] = datetime.fromtimestamp(row[5] + td)
except ValueError:
info["imageDate"] = datetime.date(1970, 1, 1)
info["imageDate"] = datetime(1970, 1, 1)
info["imageTimeZoneOffsetSeconds"] = row[6]
info["hidden"] = row[9]
@@ -1653,6 +1641,9 @@ class PhotosDB:
info["original_resource_choice"] = row[27]
info["raw_is_original"] = True if row[27] == 1 else False
# recently deleted items
info["intrash"] = True if row[28] == 1 else False
# associated RAW image info
# will be filled in later
info["has_raw"] = False
@@ -1724,8 +1715,6 @@ class PhotosDB:
# determine if a photo is missing in Photos 5
# Get info on remote/local availability for photos in shared albums
# Shared photos have a null fingerprint (and some other photos do too)
# TODO: There may be a bug here, perhaps ZDATASTORESUBTYPE should be 1 --> it's the longest ZDATALENGTH (is this the original)
c.execute(
""" SELECT
ZGENERICASSET.ZUUID,
@@ -1734,10 +1723,7 @@ class PhotosDB:
FROM ZGENERICASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
WHERE ZDATASTORESUBTYPE = 0 OR ZDATASTORESUBTYPE = 3 """
# WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
# WHERE ZDATASTORESUBTYPE = 0 OR ZDATASTORESUBTYPE = 3 """
# WHERE ZINTERNALRESOURCE.ZFINGERPRINT IS NULL AND ZINTERNALRESOURCE.ZDATASTORESUBTYPE = 3 """
WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
)
for row in c:
@@ -1870,6 +1856,9 @@ class PhotosDB:
# process exif info
self._process_exifinfo()
# process computed scores
self._process_scoreinfo()
# done processing, dump debug data if requested
if _debug():
logging.debug("Faces (_dbfaces_uuid):")
@@ -1938,6 +1927,7 @@ class PhotosDB:
return folders
def _album_folder_hierarchy_list(self, album_uuid):
""" return appropriate album_folder_hierarchy_list for the _db_version """
if self._db_version <= _PHOTOS_4_VERSION:
return self._album_folder_hierarchy_list_4(album_uuid)
else:
@@ -1948,8 +1938,11 @@ class PhotosDB:
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
# title = photosdb._dbalbum_details[album_uuid]["title"]
folders = self._dbalbum_folders[album_uuid]
try:
folders = self._dbalbum_folders[album_uuid]
except KeyError:
logging.debug(f"Caught _dbalbum_folders KeyError for album: {album_uuid}")
return []
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
@@ -1982,8 +1975,11 @@ class PhotosDB:
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
# title = photosdb._dbalbum_details[album_uuid]["title"]
folders = self._dbalbum_folders[album_uuid]
try:
folders = self._dbalbum_folders[album_uuid]
except KeyError:
logging.debug(f"Caught _dbalbum_folders KeyError for album: {album_uuid}")
return []
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
@@ -2083,6 +2079,65 @@ class PhotosDB:
hierarchy = _recurse_folder_hierarchy(folders)
return hierarchy
def _get_album_uuids(self, shared=False):
""" Return list of album UUIDs found in photos database
Filters out albums in the trash and any special album types
Args:
shared: boolean; if True, returns shared albums, else normal albums
Returns: list of album UUIDs
"""
if self._db_version <= _PHOTOS_4_VERSION:
version4 = True
if shared:
logging.warning(
f"Shared albums not implemented for Photos library version {self._db_version}"
)
return [] # not implemented for _PHOTOS_4_VERSION
else:
album_kind = _PHOTOS_4_ALBUM_KIND
else:
version4 = False
album_kind = _PHOTOS_5_SHARED_ALBUM_KIND if shared else _PHOTOS_5_ALBUM_KIND
album_list = []
# look through _dbalbum_details because _dbalbums_album won't have empty albums it
for album, detail in self._dbalbum_details.items():
if (
detail["kind"] == album_kind
and not detail["intrash"]
and (
(shared and detail["cloudownerhashedpersonid"] is not None)
or (not shared and detail["cloudownerhashedpersonid"] is None)
)
and (
not version4
# in Photos 4, special albums like "printAlbum" have kind _PHOTOS_4_ALBUM_KIND
# but should not be listed here; they can be distinguished by looking
# for folderUuid of _PHOTOS_4_ROOT_FOLDER as opposed to _PHOTOS_4_TOP_LEVEL_ALBUM
or (version4 and detail["folderUuid"] != _PHOTOS_4_ROOT_FOLDER)
)
):
album_list.append(album)
return album_list
def _get_albums(self, shared=False):
""" Return list of album titles found in photos database
Albums may have duplicate titles -- these will be treated as a single album.
Filters out albums in the trash and any special album types
Args:
shared: boolean; if True, returns shared albums, else normal albums
Returns: list of album names
"""
album_uuids = self._get_album_uuids(shared=shared)
return list({self._dbalbum_details[album]["title"] for album in album_uuids})
def photos(
self,
keywords=None,
@@ -2090,9 +2145,10 @@ class PhotosDB:
persons=None,
albums=None,
images=True,
movies=False,
movies=True,
from_date=None,
to_date=None,
intrash=False,
):
"""
Return a list of PhotoInfo objects
@@ -2106,9 +2162,11 @@ class PhotosDB:
persons: list of persons to search for
albums: list of album names to search for
images: if True, returns image files, if False, does not return images; default is True
movies: if True, returns movie files, if False, does not return movies; default is False
movies: if True, returns movie files, if False, does not return movies; default is True
from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
intrash: if True, returns only images in "Recently deleted items" folder,
if False returns only photos that aren't deleted; default is False
"""
# implementation is a bit kludgy but it works
@@ -2116,6 +2174,15 @@ class PhotosDB:
# use results to build a list of PhotoInfo objects
photos_sets = [] # list of photo sets to perform intersection of
if intrash:
photos_sets.append(
{p for p in self._dbphotos if self._dbphotos[p]["intrash"]}
)
else:
photos_sets.append(
{p for p in self._dbphotos if not self._dbphotos[p]["intrash"]}
)
if not any([keywords, uuid, persons, albums, from_date, to_date]):
# return all the photos, filtering for images and movies
# append keys of all photos as a single set to photos_sets
@@ -2124,12 +2191,15 @@ class PhotosDB:
if albums:
album_set = set()
for album in albums:
# TODO: can have >1 album with same name. This globs them together.
# Need a way to select which album?
# glob together albums with same name
if album in self._dbalbum_titles:
title_set = set()
for album_id in self._dbalbum_titles[album]:
title_set.update(self._dbalbums_album[album_id])
try:
title_set.update(self._dbalbums_album[album_id])
except KeyError:
# an empty album will be in _dbalbum_titles but not _dbalbums_album
pass
album_set.update(title_set)
else:
logging.debug(f"Could not find album '{album}' in database")
@@ -2162,7 +2232,7 @@ class PhotosDB:
logging.debug(f"Could not find person '{person}' in database")
photos_sets.append(person_set)
if from_date or to_date:
if from_date or to_date: # sourcery off
dsel = self._dbphotos
if from_date:
dsel = {
@@ -2179,7 +2249,6 @@ class PhotosDB:
photoinfo = []
if photos_sets: # found some photos
# get the intersection of each argument/search criteria
logging.debug(f"Got photo_sets: {photos_sets}")
for p in set.intersection(*photos_sets):
# filter for non-selected burst photos
if self._dbphotos[p]["burst"] and not self._dbphotos[p]["burst_key"]:
@@ -2208,5 +2277,7 @@ class PhotosDB:
return False
def __len__(self):
""" returns number of photos in the database """
""" Returns number of photos in the database
Includes recently deleted photos and non-selected burst images
"""
return len(self._dbphotos)

651
osxphotos/phototemplate.py Normal file
View File

@@ -0,0 +1,651 @@
""" Custom template system for osxphotos (implemented in PhotoInfo.render_template) """
# 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 locale
import os
import re
import pathlib
from ._constants import _UNKNOWN_PERSON
from .datetime_formatter import DateTimeFormatter
# ensure locale set to user's locale
locale.setlocale(locale.LC_ALL, "")
# Permitted substitutions (each of these returns a single value or None)
TEMPLATE_SUBSTITUTIONS = {
"{name}": "Current 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.dd}": "2-digit day of the month (zero padded) of file creation time",
"{created.dow}": "Day of week in 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)",
"{created.hour}": "2-digit hour of the file creation time",
"{created.min}": "2-digit minute of the file creation time",
"{created.sec}": "2-digit second of the file creation time",
"{created.strftime}": "Apply strftime template to file creation date/time. Should be used in form "
+ "{created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+ "{created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+ "If used with no template will return null value. "
+ "See https://strftime.org/ for help on strftime templates.",
"{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.dd}": "2-digit day of the month (zero padded) 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)",
"{modified.hour}": "2-digit hour of the file modification time",
"{modified.min}": "2-digit minute of the file modification time",
"{modified.sec}": "2-digit second of the file modification time",
# "{modified.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
# + "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
# + "{modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
# + "If used with no template will return null value. "
# + "See https://strftime.org/ for help on strftime templates.",
"{today.date}": "Current date in iso format, e.g. '2020-03-22'",
"{today.year}": "4-digit year of current date",
"{today.yy}": "2-digit year of current date",
"{today.mm}": "2-digit month of the current date (zero padded)",
"{today.month}": "Month name in user's locale of the current date",
"{today.mon}": "Month abbreviation in the user's locale of the current date",
"{today.dd}": "2-digit day of the month (zero padded) of current date",
"{today.dow}": "Day of week in user's locale of the current date",
"{today.doy}": "3-digit day of year (e.g Julian day) of current date, starting from 1 (zero padded)",
"{today.hour}": "2-digit hour of the current date",
"{today.min}": "2-digit minute of the current date",
"{today.sec}": "2-digit second of the current date",
"{today.strftime}": "Apply strftime template to current date/time. Should be used in form "
+ "{today.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
+ "{today.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
+ "If used with no template will return null value. "
+ "See https://strftime.org/ for help on strftime templates.",
"{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",
"{label}": "Image categorization label associated with a photo (Photos 5 only)",
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
}
# Just the multi-valued substitution names without the braces
MULTI_VALUE_SUBSTITUTIONS = [
field.replace("{", "").replace("}", "")
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
]
class PhotoTemplate:
""" PhotoTemplate class to render a template string from a PhotoInfo object """
def __init__(self, photo):
""" Inits PhotoTemplate class with photo, non_str, and path_sep
Args:
photo: a PhotoInfo instance.
"""
self.photo = photo
# holds value of current date/time for {today.x} fields
# gets initialized in get_template_value
self.today = None
def render(
self,
template,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
):
""" Render a filename or directory template
Args:
template: str template
none_str: str to use default for None values, default is '_'
path_sep: optional character to use as path separator, default is os.path.sep
expand_inplace: expand multi-valued substitutions in-place as a single string
instead of returning individual strings
inplace_sep: optional string to use as separator between multi-valued keywords
with expand_inplace; default is ','
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
if path_sep is None:
path_sep = os.path.sep
elif path_sep is not None and len(path_sep) != 1:
raise ValueError(f"path_sep must be single character: {path_sep}")
if inplace_sep is None:
inplace_sep = ","
# 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)}")
def make_subst_function(self, none_str, get_func=self.get_template_value):
""" returns: substitution function for use in re.sub
none_str: value to use if substitution lookup is None and no default provided
get_func: function that gets the substitution value for a given template field
default is get_template_value which handles the single-value fields """
# closure to capture photo, none_str in subst
def subst(matchobj):
groups = len(matchobj.groups())
if groups == 4:
try:
val = get_func(matchobj.group(1), matchobj.group(3))
except ValueError:
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(self, 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:
# Build a regex that matches only the field being processed
re_str = r"(?<!\\)\{(" + field + r")(,(([\w\-\%. ]{0,})))?\}"
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:
if regex_multi.search(str_template):
values = self.get_template_value_multi(field, path_sep)
if expand_inplace:
# instead of returning multiple strings, join values into a single string
val = (
inplace_sep.join(sorted(values))
if values and values[0]
else None
)
def lookup_template_value_multi(lookup_value, default):
""" 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
default is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(f"Unexpected value: {lookup_value}")
subst = make_subst_function(
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
# update rendered_strings for the next field to process
rendered_strings = {new_string}
else:
# create a new template string for each value
for val in values:
def lookup_template_value_multi(lookup_value, default):
""" 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
default is not used but required so signature matches get_template_value """
if lookup_value == field:
return val
else:
raise ValueError(
f"Unexpected value: {lookup_value}"
)
subst = make_subst_function(
self, none_str, get_func=lookup_template_value_multi
)
new_string = regex_multi.sub(subst, str_template)
new_strings.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
def get_template_value(self, field, default):
"""lookup value for template field (single-value template substitutions)
Args:
field: template field to find value for.
default: the default value provided by the user
Returns:
The matching template value (which may be None).
Raises:
ValueError if no rule exists for field.
"""
# initialize today with current date/time if needed
if self.today is None:
self.today = datetime.datetime.now()
# must be a valid keyword
if field == "name":
return pathlib.Path(self.photo.filename).stem
if field == "original_name":
return pathlib.Path(self.photo.original_filename).stem
if field == "title":
return self.photo.title
if field == "descr":
return self.photo.description
if field == "created.date":
return DateTimeFormatter(self.photo.date).date
if field == "created.year":
return DateTimeFormatter(self.photo.date).year
if field == "created.yy":
return DateTimeFormatter(self.photo.date).yy
if field == "created.mm":
return DateTimeFormatter(self.photo.date).mm
if field == "created.month":
return DateTimeFormatter(self.photo.date).month
if field == "created.mon":
return DateTimeFormatter(self.photo.date).mon
if field == "created.dd":
return DateTimeFormatter(self.photo.date).dd
if field == "created.dow":
return DateTimeFormatter(self.photo.date).dow
if field == "created.doy":
return DateTimeFormatter(self.photo.date).doy
if field == "created.hour":
return DateTimeFormatter(self.photo.date).hour
if field == "created.min":
return DateTimeFormatter(self.photo.date).min
if field == "created.sec":
return DateTimeFormatter(self.photo.date).sec
if field == "created.strftime":
if default:
try:
return self.photo.date.strftime(default)
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
return None
if field == "modified.date":
return (
DateTimeFormatter(self.photo.date_modified).date
if self.photo.date_modified
else None
)
if field == "modified.year":
return (
DateTimeFormatter(self.photo.date_modified).year
if self.photo.date_modified
else None
)
if field == "modified.yy":
return (
DateTimeFormatter(self.photo.date_modified).yy
if self.photo.date_modified
else None
)
if field == "modified.mm":
return (
DateTimeFormatter(self.photo.date_modified).mm
if self.photo.date_modified
else None
)
if field == "modified.month":
return (
DateTimeFormatter(self.photo.date_modified).month
if self.photo.date_modified
else None
)
if field == "modified.mon":
return (
DateTimeFormatter(self.photo.date_modified).mon
if self.photo.date_modified
else None
)
if field == "modified.dd":
return (
DateTimeFormatter(self.photo.date_modified).dd
if self.photo.date_modified
else None
)
if field == "modified.doy":
return (
DateTimeFormatter(self.photo.date_modified).doy
if self.photo.date_modified
else None
)
if field == "modified.hour":
return (
DateTimeFormatter(self.photo.date_modified).hour
if self.photo.date_modified
else None
)
if field == "modified.min":
return (
DateTimeFormatter(self.photo.date_modified).min
if self.photo.date_modified
else None
)
if field == "modified.sec":
return (
DateTimeFormatter(self.photo.date_modified).sec
if self.photo.date_modified
else None
)
# TODO: disabling modified.strftime for now because now clean way to pass
# a default value if modified time is None
# if field == "modified.strftime":
# if default and self.photo.date_modified:
# try:
# return self.photo.date_modified.strftime(default)
# except:
# raise ValueError(f"Invalid strftime template: '{default}'")
# else:
# return None
if field == "today.date":
return DateTimeFormatter(self.today).date
if field == "today.year":
return DateTimeFormatter(self.today).year
if field == "today.yy":
return DateTimeFormatter(self.today).yy
if field == "today.mm":
return DateTimeFormatter(self.today).mm
if field == "today.month":
return DateTimeFormatter(self.today).month
if field == "today.mon":
return DateTimeFormatter(self.today).mon
if field == "today.dd":
return DateTimeFormatter(self.today).dd
if field == "today.dow":
return DateTimeFormatter(self.today).dow
if field == "today.doy":
return DateTimeFormatter(self.today).doy
if field == "today.hour":
return DateTimeFormatter(self.today).hour
if field == "today.min":
return DateTimeFormatter(self.today).min
if field == "today.sec":
return DateTimeFormatter(self.today).sec
if field == "today.strftime":
if default:
try:
return self.today.strftime(default)
except:
raise ValueError(f"Invalid strftime template: '{default}'")
else:
return None
if field == "place.name":
return self.photo.place.name if self.photo.place else None
if field == "place.country_code":
return self.photo.place.country_code if self.photo.place else None
if field == "place.name.country":
return (
self.photo.place.names.country[0]
if self.photo.place and self.photo.place.names.country
else None
)
if field == "place.name.state_province":
return (
self.photo.place.names.state_province[0]
if self.photo.place and self.photo.place.names.state_province
else None
)
if field == "place.name.city":
return (
self.photo.place.names.city[0]
if self.photo.place and self.photo.place.names.city
else None
)
if field == "place.name.area_of_interest":
return (
self.photo.place.names.area_of_interest[0]
if self.photo.place and self.photo.place.names.area_of_interest
else None
)
if field == "place.address":
return (
self.photo.place.address_str
if self.photo.place and self.photo.place.address_str
else None
)
if field == "place.address.street":
return (
self.photo.place.address.street
if self.photo.place and self.photo.place.address.street
else None
)
if field == "place.address.city":
return (
self.photo.place.address.city
if self.photo.place and self.photo.place.address.city
else None
)
if field == "place.address.state_province":
return (
self.photo.place.address.state_province
if self.photo.place and self.photo.place.address.state_province
else None
)
if field == "place.address.postal_code":
return (
self.photo.place.address.postal_code
if self.photo.place and self.photo.place.address.postal_code
else None
)
if field == "place.address.country":
return (
self.photo.place.address.country
if self.photo.place and self.photo.place.address.country
else None
)
if field == "place.address.country_code":
return (
self.photo.place.address.iso_country_code
if self.photo.place and self.photo.place.address.iso_country_code
else None
)
# if here, didn't get a match
raise ValueError(f"Unhandled template value: {field}")
def get_template_value_multi(self, field, path_sep):
"""lookup value for template field (multi-value template substitutions)
Args:
field: template field to find value for.
path_sep: path separator to use for folder_album field
Returns:
List of the matching template values or [None].
Raises:
ValueError if no rule exists for field.
"""
""" return list of values for a multi-valued template field """
if field == "album":
values = self.photo.albums
elif field == "keyword":
values = self.photo.keywords
elif field == "person":
values = self.photo.persons
# remove any _UNKNOWN_PERSON values
values = [val for val in values if val != _UNKNOWN_PERSON]
elif field == "label":
values = self.photo.labels
elif field == "label_normalized":
values = self.photo.labels_normalized
elif field == "folder_album":
values = []
# photos must be in an album to be in a folder
for album in self.photo.album_info:
if album.folder_names:
# album in folder
folder = path_sep.join(album.folder_names)
folder += 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]
return values

View File

@@ -87,19 +87,19 @@ class PLRevGeoLocationInfo:
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
return all(
getattr(self, field) == getattr(other, field)
for field in [
"addressString",
"countryCode",
"isHome",
"compoundNames",
"compoundSecondaryNames",
"version",
"geoServiceProvider",
"postalAddress",
]
)
def __ne__(self, other):
return not self.__eq__(other)
@@ -151,21 +151,17 @@ class PLRevGeoMapItem:
self.finalPlaceInfos = finalPlaceInfos
def __eq__(self, other):
for field in ["sortedPlaceInfos", "finalPlaceInfos"]:
if getattr(self, field) != getattr(other, field):
return False
return True
return all(
getattr(self, field) == getattr(other, field)
for field in ["sortedPlaceInfos", "finalPlaceInfos"]
)
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))
sortedPlaceInfos = [str(place) for place in self.sortedPlaceInfos]
finalPlaceInfos = [str(place) for place in self.finalPlaceInfos]
return (
f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
)
@@ -192,10 +188,10 @@ class PLRevGeoMapItemAdditionalPlaceInfo:
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
return all(
getattr(self, field) == getattr(other, field)
for field in ["area", "name", "placeType", "dominantOrderType"]
)
def __ne__(self, other):
return not self.__eq__(other)
@@ -245,19 +241,19 @@ class CNPostalAddress:
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
return all(
getattr(self, field) == getattr(other, field)
for field in [
"_ISOCountryCode",
"_city",
"_country",
"_postalCode",
"_state",
"_street",
"_subAdministrativeArea",
"_subLocality",
]
)
def __ne__(self, other):
return not self.__eq__(other)
@@ -490,16 +486,14 @@ class PlaceInfo4(PlaceInfo):
"names": self.names,
"country_code": self.country_code,
}
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
return strval
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
def as_dict(self):
info = {
return {
"name": self.name,
"names": self.names._asdict(),
"country_code": self.country_code,
}
return info
class PlaceInfo5(PlaceInfo):
@@ -509,7 +503,6 @@ class PlaceInfo5(PlaceInfo):
""" 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()
@@ -541,17 +534,23 @@ class PlaceInfo5(PlaceInfo):
@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
if addr is not None:
postal_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,
)
else:
postal_address = PostalAddress(
None, None, None, None, None, None, None, None
)
return postal_address
def _process_place_info(self):
""" Process sortedPlaceInfos to set self._name and self._names """
@@ -630,16 +629,14 @@ class PlaceInfo5(PlaceInfo):
"address_str": self.address_str,
"address": str(self.address),
}
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
return strval
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
def as_dict(self):
info = {
return {
"name": self.name,
"names": self.names._asdict(),
"country_code": self.country_code,
"ishome": self.ishome,
"address_str": self.address_str,
"address": self.address._asdict(),
"address": self.address._asdict() if self.address is not None else None,
}
return info

View File

@@ -77,7 +77,7 @@
<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_description(description)}
${dc_title(photo.title)}
${dc_subject(subjects)}
${dc_datecreated(photo.date)}

View File

@@ -18,7 +18,7 @@ import CoreServices
import objc
from Foundation import *
from osxphotos._applescript import AppleScript
from .fileutil import FileUtil
_DEBUG = False
@@ -118,60 +118,6 @@ def _dd_to_dms(dd):
return int(deg_), int(min_), sec_
def _hardlink_file(src, dest):
""" Hardlinks a file from src path to dest path
src: source path as string
dest: destination path as string
Raises exception if linking 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 error on copy, subprocess will raise CalledProcessError
try:
os.link(src, dest)
except Exception as e:
logging.critical(f"os.link returned error: {e}")
raise e
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 """
@@ -203,7 +149,7 @@ def dd_to_dms_str(lat, lon):
def get_system_library_path():
""" return the path to the system Photos library as string """
""" only works on MacOS 10.15+ """
""" only works on MacOS 10.15 """
""" on earlier versions, returns None """
_, major, _ = _get_os_version()
if int(major) < 15:
@@ -220,16 +166,10 @@ def get_system_library_path():
with open(plist_file, "rb") as fp:
pl = plistload(fp)
else:
logging.warning(f"could not find plist file: {str(plist_file)}")
logging.debug(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
return pl.get("SystemLibraryPath")
def get_last_library_path():
@@ -248,7 +188,7 @@ def get_last_library_path():
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
# this is a serialized CFData object
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
photosurlref = pl.get("IPXDefaultLibraryURLBookmark")
if photosurlref is not None:
# use CFURLCreateByResolvingBookmarkData to de-serialize bookmark data into a CFURLRef
@@ -308,24 +248,6 @@ def list_photo_libraries():
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'
@@ -333,10 +255,9 @@ def get_preferred_uti_extension(uti):
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
ext = CoreServices.UTTypeCopyPreferredTagWithClass(
return CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
return ext
def findfiles(pattern, path_):
@@ -365,117 +286,6 @@ def findfiles(pattern, path_):
# 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) """
@@ -527,7 +337,7 @@ def _db_is_locked(dbname):
# except KeyError:
# uuid_str = None
# return uuid_str
# def set_uuid_for_file(filepath, uuid):
# """ sets the UUID associated with an exported file
# filepath: path to exported photo

View File

@@ -29,8 +29,6 @@ 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==6.0.1
pyobjc-core==6.0.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -5,6 +5,6 @@
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-04-25T23:54:43Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-04-26T06:26:10Z</date>
<date>2020-06-27T16:03:48Z</date>
</dict>
</plist>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-04-25T23:54:29Z</date>
<date>2020-06-27T16:03:43Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>606</integer>
<integer>651</integer>
<key>LibraryBuildTag</key>
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
<key>LibrarySchemaVersion</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View File

@@ -24,7 +24,7 @@
<key>SnapshotCompletedDate</key>
<date>2019-07-27T13:16:43Z</date>
<key>SnapshotLastValidated</key>
<date>2020-04-25T23:56:35Z</date>
<date>2020-06-27T16:03:33Z</date>
<key>SnapshotTables</key>
<dict/>
</dict>

View File

@@ -7,7 +7,7 @@
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>725</integer>
<integer>721</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

View File

@@ -3,24 +3,24 @@
<plist version="1.0">
<dict>
<key>BackgroundHighlightCollection</key>
<date>2020-05-01T23:03:12Z</date>
<date>2020-05-25T17:08:24Z</date>
<key>BackgroundHighlightEnrichment</key>
<date>2020-05-01T23:03:11Z</date>
<date>2020-05-25T17:08:24Z</date>
<key>BackgroundJobAssetRevGeocode</key>
<date>2020-05-02T01:35:19Z</date>
<date>2020-05-25T17:08:25Z</date>
<key>BackgroundJobSearch</key>
<date>2020-05-01T23:03:12Z</date>
<date>2020-05-25T17:08:25Z</date>
<key>BackgroundPeopleSuggestion</key>
<date>2020-05-01T23:03:11Z</date>
<date>2020-05-25T17:08:24Z</date>
<key>BackgroundUserBehaviorProcessor</key>
<date>2020-05-01T23:03:13Z</date>
<date>2020-05-25T17:08:25Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
<date>2020-05-02T01:35:36Z</date>
<date>2020-05-25T17:12:44Z</date>
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
<date>2020-05-01T23:03:11Z</date>
<date>2020-05-25T17:08:24Z</date>
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
<date>2020-05-02T01:35:19Z</date>
<date>2020-05-25T17:08:25Z</date>
<key>SiriPortraitDonation</key>
<date>2020-05-01T23:03:13Z</date>
<date>2020-05-25T17:08:25Z</date>
</dict>
</plist>

View File

@@ -3,8 +3,8 @@
<plist version="1.0">
<dict>
<key>FaceIDModelLastGenerationKey</key>
<date>2020-05-01T23:03:14Z</date>
<date>2020-05-25T17:08:25Z</date>
<key>LastContactClassificationKey</key>
<date>2020-05-01T23:03:18Z</date>
<date>2020-05-25T17:08:27Z</date>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LibrarySchemaVersion</key>
<integer>5001</integer>
<key>MetaSchemaVersion</key>
<integer>3</integer>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
<?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>hostname</key>
<string>Rhets-MacBook-Pro.local</string>
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<key>pid</key>
<integer>763</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>
<integer>501</integer>
</dict>
</plist>

Binary file not shown.

View File

@@ -0,0 +1,188 @@
<?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>BlacklistedMeaningsByMeaning</key>
<dict/>
<key>MePersonUUID</key>
<string>39488755-78C0-40B2-B378-EDA280E1823C</string>
<key>SceneWhitelist</key>
<array>
<string>Graduation</string>
<string>Aquarium</string>
<string>Food</string>
<string>Ice Skating</string>
<string>Mountain</string>
<string>Cliff</string>
<string>Basketball</string>
<string>Tennis</string>
<string>Jewelry</string>
<string>Cheese</string>
<string>Softball</string>
<string>Football</string>
<string>Circus</string>
<string>Jet Ski</string>
<string>Playground</string>
<string>Carousel</string>
<string>Paint Ball</string>
<string>Windsurfing</string>
<string>Sailboat</string>
<string>Sunbathing</string>
<string>Dam</string>
<string>Fireplace</string>
<string>Flower</string>
<string>Scuba</string>
<string>Hiking</string>
<string>Cetacean</string>
<string>Pier</string>
<string>Bowling</string>
<string>Snowboarding</string>
<string>Zoo</string>
<string>Snowmobile</string>
<string>Theater</string>
<string>Boat</string>
<string>Casino</string>
<string>Car</string>
<string>Diving</string>
<string>Cycling</string>
<string>Musical Instrument</string>
<string>Board Game</string>
<string>Castle</string>
<string>Sunset Sunrise</string>
<string>Martial Arts</string>
<string>Motocross</string>
<string>Submarine</string>
<string>Cat</string>
<string>Snow</string>
<string>Kiteboarding</string>
<string>Squash</string>
<string>Geyser</string>
<string>Music</string>
<string>Archery</string>
<string>Desert</string>
<string>Blackjack</string>
<string>Fireworks</string>
<string>Sportscar</string>
<string>Feline</string>
<string>Soccer</string>
<string>Museum</string>
<string>Baby</string>
<string>Fencing</string>
<string>Railroad</string>
<string>Nascar</string>
<string>Sky Surfing</string>
<string>Bird</string>
<string>Games</string>
<string>Baseball</string>
<string>Dressage</string>
<string>Snorkeling</string>
<string>Pyramid</string>
<string>Kite</string>
<string>Rowboat</string>
<string>Golf</string>
<string>Watersports</string>
<string>Lightning</string>
<string>Canyon</string>
<string>Auditorium</string>
<string>Night Sky</string>
<string>Karaoke</string>
<string>Skiing</string>
<string>Parade</string>
<string>Forest</string>
<string>Hot Air Balloon</string>
<string>Dragon Parade</string>
<string>Easter Egg</string>
<string>Monument</string>
<string>Jungle</string>
<string>Thanksgiving</string>
<string>Jockey Horse</string>
<string>Stadium</string>
<string>Airplane</string>
<string>Ballet</string>
<string>Yoga</string>
<string>Coral Reef</string>
<string>Skating</string>
<string>Wrestling</string>
<string>Bicycle</string>
<string>Tattoo</string>
<string>Amusement Park</string>
<string>Canoe</string>
<string>Cheerleading</string>
<string>Ping Pong</string>
<string>Fishing</string>
<string>Magic</string>
<string>Reptile</string>
<string>Winter Sport</string>
<string>Waterfall</string>
<string>Train</string>
<string>Bonsai</string>
<string>Surfing</string>
<string>Dog</string>
<string>Cake</string>
<string>Sledding</string>
<string>Sandcastle</string>
<string>Glacier</string>
<string>Lighthouse</string>
<string>Equestrian</string>
<string>Rafting</string>
<string>Shore</string>
<string>Hockey</string>
<string>Santa Claus</string>
<string>Formula One Car</string>
<string>Sport</string>
<string>Vehicle</string>
<string>Boxing</string>
<string>Rollerskating</string>
<string>Underwater</string>
<string>Orchestra</string>
<string>Carnival</string>
<string>Rocket</string>
<string>Skateboarding</string>
<string>Helicopter</string>
<string>Performance</string>
<string>Oktoberfest</string>
<string>Water Polo</string>
<string>Skate Park</string>
<string>Animal</string>
<string>Nightclub</string>
<string>String Instrument</string>
<string>Dinosaur</string>
<string>Gymnastics</string>
<string>Cricket</string>
<string>Volcano</string>
<string>Lake</string>
<string>Aurora</string>
<string>Dancing</string>
<string>Concert</string>
<string>Rock Climbing</string>
<string>Hang Glider</string>
<string>Rodeo</string>
<string>Fish</string>
<string>Art</string>
<string>Motorcycle</string>
<string>Volleyball</string>
<string>Wake Boarding</string>
<string>Badminton</string>
<string>Motor Sport</string>
<string>Sumo</string>
<string>Parasailing</string>
<string>Skydiving</string>
<string>Kickboxing</string>
<string>Pinata</string>
<string>Foosball</string>
<string>Go Kart</string>
<string>Poker</string>
<string>Kayak</string>
<string>Swimming</string>
<string>Atv</string>
<string>Beach</string>
<string>Dartboard</string>
<string>Athletics</string>
<string>Camping</string>
<string>Tornado</string>
<string>Billiards</string>
<string>Rugby</string>
<string>Airshow</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,26 @@
<?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>insertAlbum</key>
<array/>
<key>insertAsset</key>
<array/>
<key>insertHighlight</key>
<array/>
<key>insertMemory</key>
<array/>
<key>insertMoment</key>
<array/>
<key>removeAlbum</key>
<array/>
<key>removeAsset</key>
<array/>
<key>removeHighlight</key>
<array/>
<key>removeMemory</key>
<array/>
<key>removeMoment</key>
<array/>
</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>embeddingVersion</key>
<string>1</string>
<key>localeIdentifier</key>
<string>en_US</string>
<key>sceneTaxonomySHA</key>
<string>87914a047c69fbe8013fad2c70fa70c6c03b08b56190fe4054c880e6b9f57cc3</string>
<key>searchIndexVersion</key>
<string>10</string>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

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