Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef518cc3e | ||
|
|
a934b692ab | ||
|
|
9d820a0557 | ||
|
|
fcff8ec5f8 | ||
|
|
dfcbfa725a | ||
|
|
df75a05645 | ||
|
|
80f5989e2c | ||
|
|
8c3af0a4e4 | ||
|
|
4523224276 | ||
|
|
541c390b7b | ||
|
|
6ab0ad7e86 | ||
|
|
e5755c6144 | ||
|
|
7806e05673 | ||
|
|
bb4bc8fd96 | ||
|
|
59507077ba | ||
|
|
ff0328785f | ||
|
|
3693d65b82 | ||
|
|
6a85bd215a | ||
|
|
ab36264af0 | ||
|
|
185483e1aa | ||
|
|
c1d12047bd | ||
|
|
46c87eeed5 | ||
|
|
fd4c99032d | ||
|
|
d6fee89fd9 | ||
|
|
b8618cf272 | ||
|
|
6b7c5d07fd | ||
|
|
bd5ba702aa | ||
|
|
c8d76a89e4 | ||
|
|
a8e996e660 | ||
|
|
c68a5ab39f | ||
|
|
1ebf995833 | ||
|
|
538bac7ade | ||
|
|
32806c8459 | ||
|
|
cfabd0dbea | ||
|
|
a23259948c | ||
|
|
1212fad4ad | ||
|
|
567abe3311 | ||
|
|
5a832181f7 | ||
|
|
4da57a1cee | ||
|
|
1fd0f96b14 | ||
|
|
e98c3fe429 | ||
|
|
d77e9747cd | ||
|
|
43d28e78f3 | ||
|
|
00bc50490e | ||
|
|
f8743c33bd | ||
|
|
937da9e617 | ||
|
|
435868a0a7 | ||
|
|
d9802247d9 | ||
|
|
f39a92a352 | ||
|
|
40dc7d32f2 | ||
|
|
4cd6c8f617 | ||
|
|
0004250e74 | ||
|
|
868ee7737b | ||
|
|
5387f8e2f9 | ||
|
|
73b499f405 |
215
CHANGELOG.md
@@ -4,20 +4,175 @@ 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.9](https://github.com/RhetTbull/osxphotos/compare/v0.30.7...v0.30.9)
|
||||
|
||||
> 6 July 2020
|
||||
|
||||
- Refactored person processing to enable implementation of #181 [`fcff8ec`](https://github.com/RhetTbull/osxphotos/commit/fcff8ec5f8286b28e7d8559b40b5808a7b59cc15)
|
||||
- AlbumInfo.photos now returns photos in album sort order [`9d820a0`](https://github.com/RhetTbull/osxphotos/commit/9d820a0557944340d0c664a6c3497d138c6100d5)
|
||||
|
||||
#### [v0.30.7](https://github.com/RhetTbull/osxphotos/compare/v0.30.6...v0.30.7)
|
||||
|
||||
> 4 July 2020
|
||||
|
||||
- Bug fix for keywords, persons in deleted photos [`df75a05`](https://github.com/RhetTbull/osxphotos/commit/df75a05645a88b31daa411f960d99ade71efc908)
|
||||
|
||||
#### [v0.30.6](https://github.com/RhetTbull/osxphotos/compare/v0.30.5...v0.30.6)
|
||||
|
||||
> 3 July 2020
|
||||
|
||||
- Added height, width, orientation, filesize to json, str) [`8c3af0a`](https://github.com/RhetTbull/osxphotos/commit/8c3af0a4e4e49d9bbb33e809973d958334e44dca)
|
||||
|
||||
#### [v0.30.5](https://github.com/RhetTbull/osxphotos/compare/v0.30.4...v0.30.5)
|
||||
|
||||
> 3 July 2020
|
||||
|
||||
- Added height, width, orientation, filesize, closes #163 [`#163`](https://github.com/RhetTbull/osxphotos/issues/163)
|
||||
|
||||
#### [v0.30.4](https://github.com/RhetTbull/osxphotos/compare/v0.30.3...v0.30.4)
|
||||
|
||||
> 3 July 2020
|
||||
|
||||
- Added GPS location to XMP sidecar, closes #175 [`#175`](https://github.com/RhetTbull/osxphotos/issues/175)
|
||||
- Updated README.md [`7806e05`](https://github.com/RhetTbull/osxphotos/commit/7806e05673775ded231e65f53f3a1d5095a4b4e1)
|
||||
|
||||
#### [v0.30.3](https://github.com/RhetTbull/osxphotos/compare/v0.30.2...v0.30.3)
|
||||
|
||||
> 29 June 2020
|
||||
|
||||
- Added --description-template to CLI, closes #166 [`#166`](https://github.com/RhetTbull/osxphotos/issues/166)
|
||||
- Added expand_inplace to PhotoTemplate.render [`ff03287`](https://github.com/RhetTbull/osxphotos/commit/ff0328785f3ea14b1c8ae2b7d1a9b07e8aef0777)
|
||||
- Updated README.md [`5950707`](https://github.com/RhetTbull/osxphotos/commit/59507077bafe39a17bc23babe6d6c52e1f502a53)
|
||||
|
||||
#### [v0.30.2](https://github.com/RhetTbull/osxphotos/compare/v0.30.1...v0.30.2)
|
||||
|
||||
> 28 June 2020
|
||||
|
||||
- Added --deleted, --deleted-only to CLI, closes #179 [`#179`](https://github.com/RhetTbull/osxphotos/issues/179)
|
||||
|
||||
#### [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)
|
||||
- Partial fix for #155 [`2271d89`](https://github.com/RhetTbull/osxphotos/commit/2271d8935507ecc27e6227b11b4796f2f4d2f10d)
|
||||
- Partial fix for #155 [`62d096b`](https://github.com/RhetTbull/osxphotos/commit/62d096b5a1a7e960195ec5c48fc9cffbebf2c735)
|
||||
|
||||
#### [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)
|
||||
- Updated CHANGELOG.md [`d47fd46`](https://github.com/RhetTbull/osxphotos/commit/d47fd46a21881bea86d1bc624c6027e2cbe08d9c)
|
||||
|
||||
#### [v0.29.8](https://github.com/RhetTbull/osxphotos/compare/v0.29.5...v0.29.8)
|
||||
|
||||
@@ -39,7 +194,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
- 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)
|
||||
- Updated CHANGELOG.md [`af750dd`](https://github.com/RhetTbull/osxphotos/commit/af750dd2e392be1a7163cf32497526405665ea70)
|
||||
- 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)
|
||||
@@ -53,7 +208,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
> 23 May 2020
|
||||
|
||||
- Catch illegal timestamp value [`#146`](https://github.com/RhetTbull/osxphotos/pull/146)
|
||||
- Updated CHANGELOG.md [`1450b3c`](https://github.com/RhetTbull/osxphotos/commit/1450b3ccace326fe1c0ed810a1b40e781709acb3)
|
||||
- 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)
|
||||
|
||||
@@ -63,8 +218,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)
|
||||
|
||||
@@ -73,16 +228,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)
|
||||
|
||||
@@ -99,8 +253,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)
|
||||
|
||||
@@ -124,7 +278,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)
|
||||
@@ -135,7 +288,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)
|
||||
@@ -143,7 +295,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)
|
||||
|
||||
@@ -157,7 +309,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)
|
||||
|
||||
@@ -165,7 +317,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)
|
||||
|
||||
@@ -183,7 +334,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)
|
||||
|
||||
@@ -197,7 +347,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)
|
||||
|
||||
@@ -206,8 +355,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)
|
||||
|
||||
@@ -215,7 +364,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)
|
||||
|
||||
@@ -223,7 +371,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)
|
||||
|
||||
@@ -242,7 +390,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)
|
||||
|
||||
@@ -263,8 +411,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)
|
||||
|
||||
@@ -279,15 +427,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)
|
||||
@@ -303,7 +451,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)
|
||||
|
||||
@@ -318,18 +466,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)
|
||||
@@ -346,7 +493,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)
|
||||
|
||||
@@ -358,7 +505,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)
|
||||
|
||||
@@ -372,7 +519,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)
|
||||
|
||||
@@ -384,7 +531,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)
|
||||
|
||||
@@ -392,7 +539,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)
|
||||
|
||||
@@ -407,8 +554,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)
|
||||
|
||||
@@ -422,9 +569,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)
|
||||
|
||||
|
||||
214
README.md
@@ -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,6 +207,10 @@ 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
|
||||
@@ -243,6 +253,16 @@ Options:
|
||||
--keyword-template "{folder_album}"
|
||||
--keyword-template "{created.year}" See
|
||||
Templating System below.
|
||||
--description-template TEMPLATE
|
||||
For use with --exiftool, --sidecar; specify
|
||||
a template string to use as description in
|
||||
the form '{name,DEFAULT}' This is the same
|
||||
format as --directory. For example, if you
|
||||
wanted to append 'exported with osxphotos on
|
||||
[today's date]' to the description, you
|
||||
could specify --description-template
|
||||
"{descr} exported with osxphotos on
|
||||
{today.date}" See Templating System below.
|
||||
--current-name Use photo's current filename instead of
|
||||
original filename for export. Note:
|
||||
Starting with Photos 5, all photos are
|
||||
@@ -367,7 +387,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.
|
||||
above example, this would result in '2020/_/photoname.jpg' if address was
|
||||
null.
|
||||
|
||||
Substitution Description
|
||||
{name} Current filename of the photo
|
||||
@@ -391,6 +412,18 @@ Substitution Description
|
||||
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
|
||||
@@ -406,6 +439,37 @@ Substitution Description
|
||||
{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
|
||||
@@ -470,6 +534,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
|
||||
|
||||
@@ -807,8 +875,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)
|
||||
@@ -829,7 +914,8 @@ photos = photosdb.photos(
|
||||
images = bool,
|
||||
movies = bool,
|
||||
from_date = datetime.datetime,
|
||||
to_date = datetime.datetime
|
||||
to_date = datetime.datetime,
|
||||
intrash = bool,
|
||||
)
|
||||
```
|
||||
|
||||
@@ -838,9 +924,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.
|
||||
|
||||
@@ -883,15 +970,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.
|
||||
@@ -965,6 +1048,27 @@ Returns the absolute path to the edited photo on disk as a string. If the photo
|
||||
|
||||
**Note**: will also return None if the edited photo is missing on disk.
|
||||
|
||||
#### `height`
|
||||
Returns height of the photo in pixels. If image has been edited, returns height of the edited image, otherwise returns height of the original image. See also [original_height](#original_height).
|
||||
|
||||
#### `width`
|
||||
Returns width of the photo in pixels. If image has been edited, returns width of the edited image, otherwise returns width of the original image. See also [original_width](#original_width).
|
||||
|
||||
#### `orientation`
|
||||
Returns EXIF orientation value of the photo as integer. If image has been edited, returns orientation of the edited image, otherwise returns orientation of the original image. See also [original_orientation](#original_orientation).
|
||||
|
||||
#### `original_height`
|
||||
Returns height of the original photo in pixels. See also [height](#height).
|
||||
|
||||
#### `original_width`
|
||||
Returns width of the original photo in pixels. See also [width](#width).
|
||||
|
||||
#### `original_orientation`
|
||||
Returns EXIF orientation value of the original photo as integer. See also [orientation](#orientation).
|
||||
|
||||
#### `original_filesize`
|
||||
Returns size of the original photo in bytes as integer.
|
||||
|
||||
#### `ismissing`
|
||||
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted and user hasn't enabled "Copy items to the Photos library" in Photos preferences. **Note**: this status is computed based on data in the Photos library and `ismissing` does not verify if the photo is actually missing. See also [path](#path).
|
||||
|
||||
@@ -980,6 +1084,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`
|
||||
|
||||
@@ -1128,7 +1235,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
|
||||
@@ -1175,11 +1287,13 @@ If overwrite=False and increment=False, export will fail if destination file alr
|
||||
|
||||
#### <a name="rendertemplate">`render_template()`</a>
|
||||
|
||||
`render_template(template_str, 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`: 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"].
|
||||
|
||||
@@ -1240,7 +1354,7 @@ Returns the universally unique identifier (uuid) of the album. This is how Phot
|
||||
Returns the title or name of the album.
|
||||
|
||||
#### `photos`
|
||||
Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album.
|
||||
Returns a list of [PhotoInfo](#PhotoInfo) objects representing each photo contained in the album sorted in the same order as in Photos. (e.g. if photos were manually sorted in the Photos albums, photos returned by `photos` will be in same order as they appear in the Photos album)
|
||||
|
||||
#### `folder_list`
|
||||
Returns a hierarchical list of [FolderInfo](#FolderInfo) objects representing the folders the album is contained in. For example, if album "AlbumInFolder" is in SubFolder2 of Folder1 as illustrated below, would return a list of `FolderInfo` objects representing ["Folder1", "SubFolder2"]
|
||||
@@ -1374,11 +1488,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|
|
||||
@@ -1392,7 +1544,12 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|
||||
|{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|
|
||||
@@ -1401,6 +1558,22 @@ The following substitutions are availabe for use with `PhotoInfo.render_template
|
||||
|{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|
|
||||
@@ -1421,7 +1594,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
|
||||
|
||||
@@ -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,6 +18,23 @@ 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -236,6 +236,25 @@ JSON_OPTION = click.option(
|
||||
)
|
||||
|
||||
|
||||
def deleted_options(f):
|
||||
o = click.option
|
||||
options = [
|
||||
o(
|
||||
"--deleted",
|
||||
is_flag=True,
|
||||
help="Include photos from the 'Recently Deleted' folder.",
|
||||
),
|
||||
o(
|
||||
"--deleted-only",
|
||||
is_flag=True,
|
||||
help="Include only photos from the 'Recently Deleted' folder.",
|
||||
),
|
||||
]
|
||||
for o in options[::-1]:
|
||||
f = o(f)
|
||||
return f
|
||||
|
||||
|
||||
def query_options(f):
|
||||
o = click.option
|
||||
options = [
|
||||
@@ -245,7 +264,7 @@ def query_options(f):
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos with keyword KEYWORD. "
|
||||
'If more than one keyword, treated as "OR", e.g. find photos match any keyword',
|
||||
'If more than one keyword, treated as "OR", e.g. find photos matching any keyword',
|
||||
),
|
||||
o(
|
||||
"--person",
|
||||
@@ -253,7 +272,7 @@ def query_options(f):
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos with person PERSON. "
|
||||
'If more than one person, treated as "OR", e.g. find photos match any person',
|
||||
'If more than one person, treated as "OR", e.g. find photos matching any person',
|
||||
),
|
||||
o(
|
||||
"--album",
|
||||
@@ -261,7 +280,7 @@ def query_options(f):
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos in album ALBUM. "
|
||||
'If more than one album, treated as "OR", e.g. find photos match any album',
|
||||
'If more than one album, treated as "OR", e.g. find photos matching any album',
|
||||
),
|
||||
o(
|
||||
"--folder",
|
||||
@@ -311,6 +330,13 @@ def query_options(f):
|
||||
is_flag=True,
|
||||
help="Search for photos with no associated place name info (no reverse geolocation info)",
|
||||
),
|
||||
o(
|
||||
"--label",
|
||||
metavar="LABEL",
|
||||
multiple=True,
|
||||
help="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',
|
||||
),
|
||||
o(
|
||||
"--uti",
|
||||
metavar="UTI",
|
||||
@@ -322,7 +348,7 @@ def query_options(f):
|
||||
"-i",
|
||||
"--ignore-case",
|
||||
is_flag=True,
|
||||
help="Case insensitive search for title, description, or place. Does not apply to keyword, person, or album.",
|
||||
help="Case insensitive search for title, description, place, keyword, person, or album.",
|
||||
),
|
||||
o("--edited", is_flag=True, help="Search for photos that have been edited."),
|
||||
o(
|
||||
@@ -494,10 +520,14 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid):
|
||||
print("_dbkeywords_uuid:")
|
||||
pprint.pprint(photosdb._dbkeywords_uuid)
|
||||
elif attr == "persons":
|
||||
print("_dbfaces_person:")
|
||||
pprint.pprint(photosdb._dbfaces_person)
|
||||
print("_dbfaces_uuid:")
|
||||
pprint.pprint(photosdb._dbfaces_uuid)
|
||||
print("_dbfaces_pk:")
|
||||
pprint.pprint(photosdb._dbfaces_pk)
|
||||
print("_dbpersons_pk:")
|
||||
pprint.pprint(photosdb._dbpersons_pk)
|
||||
print("_dbpersons_fullname:")
|
||||
pprint.pprint(photosdb._dbpersons_fullname)
|
||||
elif attr == "photos":
|
||||
if uuid:
|
||||
for uuid_ in uuid:
|
||||
@@ -527,7 +557,9 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid):
|
||||
def keywords(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out keywords found in the Photos library. """
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(cli.commands["keywords"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
@@ -551,7 +583,9 @@ def keywords(ctx, cli_obj, db, json_, photos_library):
|
||||
def albums(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out albums found in the Photos library. """
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(cli.commands["albums"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
@@ -578,7 +612,9 @@ def albums(ctx, cli_obj, db, json_, photos_library):
|
||||
def persons(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out persons (faces) found in the Photos library. """
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(cli.commands["persons"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
@@ -593,6 +629,32 @@ def persons(ctx, cli_obj, db, json_, photos_library):
|
||||
click.echo(yaml.dump(persons, sort_keys=False))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def labels(ctx, cli_obj, db, json_, photos_library):
|
||||
""" Print out image classification labels found in the Photos library. """
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(cli.commands["labels"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
labels = {"labels": photosdb.labels_as_dict}
|
||||
if json_ or cli_obj.json:
|
||||
click.echo(json.dumps(labels))
|
||||
else:
|
||||
click.echo(yaml.dump(labels, sort_keys=False))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@@ -611,7 +673,7 @@ def info(ctx, cli_obj, db, json_, photos_library):
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
info = {"database_path": photosdb.db_path, "database_version": photosdb.db_version}
|
||||
photos = photosdb.photos()
|
||||
photos = photosdb.photos(movies=False)
|
||||
not_shared_photos = [p for p in photos if not p.shared]
|
||||
info["photo_count"] = len(not_shared_photos)
|
||||
|
||||
@@ -706,10 +768,11 @@ def places(ctx, cli_obj, db, json_, photos_library):
|
||||
@cli.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@deleted_options
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def dump(ctx, cli_obj, db, json_, photos_library):
|
||||
def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
|
||||
""" Print list of all photos & associated info from the Photos library. """
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
@@ -719,8 +782,20 @@ def dump(ctx, cli_obj, db, json_, photos_library):
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
# check exclusive options
|
||||
if deleted and deleted_only:
|
||||
click.echo("Incompatible dump options", err=True)
|
||||
click.echo(cli.commands["dump"].get_help(ctx), err=True)
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
photos = photosdb.photos(movies=True)
|
||||
if deleted or deleted_only:
|
||||
photos = photosdb.photos(movies=True, intrash=True)
|
||||
else:
|
||||
photos = []
|
||||
if not deleted_only:
|
||||
photos += photosdb.photos(movies=True)
|
||||
|
||||
print_photo_info(photos, json_ or cli_obj.json)
|
||||
|
||||
|
||||
@@ -776,6 +851,7 @@ def _list_libraries(json_=False, error=True):
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@query_options
|
||||
@deleted_options
|
||||
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
|
||||
@click.option(
|
||||
"--not-missing",
|
||||
@@ -861,6 +937,9 @@ def query(
|
||||
has_raw,
|
||||
place,
|
||||
no_place,
|
||||
label,
|
||||
deleted,
|
||||
deleted_only,
|
||||
):
|
||||
""" Query the Photos database using 1 or more search options;
|
||||
if more than one option is provided, they are treated as "AND"
|
||||
@@ -881,6 +960,7 @@ def query(
|
||||
has_raw,
|
||||
from_date,
|
||||
to_date,
|
||||
label,
|
||||
]
|
||||
exclusive = [
|
||||
(favorite, not_favorite),
|
||||
@@ -901,9 +981,12 @@ def query(
|
||||
(selfie, not_selfie),
|
||||
(panorama, not_panorama),
|
||||
(any(place), no_place),
|
||||
(deleted, deleted_only),
|
||||
]
|
||||
# print help if no non-exclusive term or a double exclusive term is given
|
||||
if not any(nonexclusive + [b ^ n for b, n in exclusive]):
|
||||
if any(all(bb) for bb in exclusive) or not any(
|
||||
nonexclusive + [b ^ n for b, n in exclusive]
|
||||
):
|
||||
click.echo("Incompatible query options", err=True)
|
||||
click.echo(cli.commands["query"].get_help(ctx), err=True)
|
||||
return
|
||||
@@ -976,6 +1059,9 @@ def query(
|
||||
has_raw=has_raw,
|
||||
place=place,
|
||||
no_place=no_place,
|
||||
label=label,
|
||||
deleted=deleted,
|
||||
deleted_only=deleted_only,
|
||||
)
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
@@ -987,6 +1073,7 @@ def query(
|
||||
@DB_OPTION
|
||||
@click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.")
|
||||
@query_options
|
||||
@deleted_options
|
||||
@click.option(
|
||||
"--update",
|
||||
is_flag=True,
|
||||
@@ -1063,6 +1150,18 @@ def query(
|
||||
'--keyword-template "{created.year}" '
|
||||
"See Templating System below.",
|
||||
)
|
||||
@click.option(
|
||||
"--description-template",
|
||||
metavar="TEMPLATE",
|
||||
multiple=False,
|
||||
default=None,
|
||||
help="For use with --exiftool, --sidecar; specify a template string to use as "
|
||||
"description in the form '{name,DEFAULT}' "
|
||||
"This is the same format as --directory. For example, if you wanted to append "
|
||||
"'exported with osxphotos on [today's date]' to the description, you could specify "
|
||||
'--description-template "{descr} exported with osxphotos on {today.date}" '
|
||||
"See Templating System below.",
|
||||
)
|
||||
@click.option(
|
||||
"--current-name",
|
||||
is_flag=True,
|
||||
@@ -1177,6 +1276,7 @@ def export(
|
||||
person_keyword,
|
||||
album_keyword,
|
||||
keyword_template,
|
||||
description_template,
|
||||
current_name,
|
||||
sidecar,
|
||||
only_photos,
|
||||
@@ -1209,6 +1309,9 @@ def export(
|
||||
place,
|
||||
no_place,
|
||||
no_extended_attributes,
|
||||
label,
|
||||
deleted,
|
||||
deleted_only,
|
||||
):
|
||||
""" Export photos from the Photos database.
|
||||
Export path DEST is required.
|
||||
@@ -1247,6 +1350,7 @@ def export(
|
||||
(export_by_date, directory),
|
||||
(export_as_hardlink, exiftool),
|
||||
(any(place), no_place),
|
||||
(deleted, deleted_only),
|
||||
]
|
||||
if any(all(bb) for bb in exclusive):
|
||||
click.echo("Incompatible export options", err=True)
|
||||
@@ -1287,13 +1391,32 @@ def export(
|
||||
return
|
||||
|
||||
# open export database and assign copy/link/unlink functions
|
||||
export_db_path = os.path.join(dest, OSXPHOTOS_EXPORT_DB)
|
||||
|
||||
# check that export isn't in the parent or child of a previously exported library
|
||||
other_db_files = find_files_in_branch(dest, OSXPHOTOS_EXPORT_DB)
|
||||
if other_db_files:
|
||||
click.echo(
|
||||
"WARNING: found other export database files in this destination directory branch. "
|
||||
+ "This likely means you are attempting to export files into a directory "
|
||||
+ "that is either the parent or a child directory of a previous export. "
|
||||
+ "Proceeding may cause your exported files to be overwritten.",
|
||||
err=True,
|
||||
)
|
||||
click.echo(
|
||||
f"You are exporting to {dest}, found {OSXPHOTOS_EXPORT_DB} files in:"
|
||||
)
|
||||
for other_db in other_db_files:
|
||||
click.echo(f"{other_db}")
|
||||
click.confirm("Do you want to continue?", abort=True)
|
||||
|
||||
if dry_run:
|
||||
export_db = ExportDBInMemory(os.path.join(dest, OSXPHOTOS_EXPORT_DB))
|
||||
export_db = ExportDBInMemory(export_db_path)
|
||||
# echo = functools.partial(click.echo, err=True)
|
||||
# fileutil = FileUtilNoOp(verbose=echo)
|
||||
fileutil = FileUtilNoOp
|
||||
else:
|
||||
export_db = ExportDB(os.path.join(dest, OSXPHOTOS_EXPORT_DB))
|
||||
export_db = ExportDB(export_db_path)
|
||||
fileutil = FileUtil
|
||||
|
||||
photos = _query(
|
||||
@@ -1348,6 +1471,9 @@ def export(
|
||||
has_raw=has_raw,
|
||||
place=place,
|
||||
no_place=no_place,
|
||||
label=label,
|
||||
deleted=deleted,
|
||||
deleted_only=deleted_only,
|
||||
)
|
||||
|
||||
results_exported = []
|
||||
@@ -1396,6 +1522,7 @@ def export(
|
||||
album_keyword=album_keyword,
|
||||
person_keyword=person_keyword,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run=dry_run,
|
||||
@@ -1432,6 +1559,7 @@ def export(
|
||||
album_keyword=album_keyword,
|
||||
person_keyword=person_keyword,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
dry_run=dry_run,
|
||||
@@ -1460,7 +1588,7 @@ def export(
|
||||
else:
|
||||
photo_str = "photos" if len(results_exported) != 1 else "photo"
|
||||
click.echo(f"Exported: {len(results_exported)} {photo_str}")
|
||||
click.echo(f"Elapsed time: {stop_time-start_time} seconds")
|
||||
click.echo(f"Elapsed time: {(stop_time-start_time):.3f} seconds")
|
||||
else:
|
||||
click.echo("Did not find any photos to export")
|
||||
|
||||
@@ -1534,6 +1662,7 @@ def print_photo_info(photos, json=False):
|
||||
"has_raw",
|
||||
"uti_raw",
|
||||
"path_raw",
|
||||
"intrash",
|
||||
]
|
||||
)
|
||||
for p in photos:
|
||||
@@ -1578,6 +1707,7 @@ def print_photo_info(photos, json=False):
|
||||
p.has_raw,
|
||||
p.uti_raw,
|
||||
p.path_raw,
|
||||
p.intrash,
|
||||
]
|
||||
)
|
||||
for row in dump:
|
||||
@@ -1636,6 +1766,9 @@ def _query(
|
||||
has_raw=None,
|
||||
place=None,
|
||||
no_place=None,
|
||||
label=None,
|
||||
deleted=False,
|
||||
deleted_only=False,
|
||||
):
|
||||
""" run a query against PhotosDB to extract the photos based on user supply criteria
|
||||
used by query and export commands
|
||||
@@ -1643,16 +1776,37 @@ def _query(
|
||||
if either is modified, need to ensure all three functions are updated """
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
photos = photosdb.photos(
|
||||
keywords=keyword,
|
||||
persons=person,
|
||||
albums=album,
|
||||
uuid=uuid,
|
||||
images=isphoto,
|
||||
movies=ismovie,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
if deleted or deleted_only:
|
||||
photos = photosdb.photos(
|
||||
uuid=uuid,
|
||||
images=isphoto,
|
||||
movies=ismovie,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
intrash=True,
|
||||
)
|
||||
else:
|
||||
photos = []
|
||||
if not deleted_only:
|
||||
photos += photosdb.photos(
|
||||
uuid=uuid,
|
||||
images=isphoto,
|
||||
movies=ismovie,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
if album:
|
||||
photos = get_photos_by_attribute(photos, "albums", album, ignore_case)
|
||||
|
||||
if keyword:
|
||||
photos = get_photos_by_attribute(photos, "keywords", keyword, ignore_case)
|
||||
|
||||
if person:
|
||||
photos = get_photos_by_attribute(photos, "persons", person, ignore_case)
|
||||
|
||||
if label:
|
||||
photos = get_photos_by_attribute(photos, "labels", label, ignore_case)
|
||||
|
||||
if folder:
|
||||
# search for photos in an album in folder
|
||||
@@ -1828,6 +1982,34 @@ def _query(
|
||||
return photos
|
||||
|
||||
|
||||
def get_photos_by_attribute(photos, attribute, values, ignore_case):
|
||||
"""Search for photos based on values being in PhotoInfo.attribute
|
||||
|
||||
Args:
|
||||
photos: a list of PhotoInfo objects
|
||||
attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc)
|
||||
values: list of values to search in property
|
||||
ignore_case: ignore case when searching
|
||||
|
||||
Returns:
|
||||
list of PhotoInfo objects matching search criteria
|
||||
"""
|
||||
photos_search = []
|
||||
if ignore_case:
|
||||
# case-insensitive
|
||||
for x in values:
|
||||
x = x.lower()
|
||||
photos_search.extend(
|
||||
p
|
||||
for p in photos
|
||||
if x in [attr.lower() for attr in getattr(p, attribute)]
|
||||
)
|
||||
else:
|
||||
for x in values:
|
||||
photos_search.extend(p for p in photos if x in getattr(p, attribute))
|
||||
return photos_search
|
||||
|
||||
|
||||
def export_photo(
|
||||
photo=None,
|
||||
dest=None,
|
||||
@@ -1849,6 +2031,7 @@ def export_photo(
|
||||
album_keyword=None,
|
||||
person_keyword=None,
|
||||
keyword_template=None,
|
||||
description_template=None,
|
||||
export_db=None,
|
||||
fileutil=FileUtil,
|
||||
dry_run=None,
|
||||
@@ -1876,6 +2059,7 @@ def export_photo(
|
||||
album_keyword: boolean; if True, exports album names as keywords in metadata
|
||||
person_keyword: boolean; if True, exports person names as keywords in metadata
|
||||
keyword_template: list of strings; if provided use rendered template strings as keywords
|
||||
description_template: string; optional template string that will be rendered for use as photo description
|
||||
export_db: export database instance compatible with ExportDB_ABC
|
||||
fileutil: file util class compatible with FileUtilABC
|
||||
dry_run: boolean; if True, doesn't actually export or update any files
|
||||
@@ -1950,6 +2134,7 @@ def export_photo(
|
||||
use_albums_as_keywords=album_keyword,
|
||||
use_persons_as_keywords=person_keyword,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
update=update,
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
@@ -2005,6 +2190,7 @@ def export_photo(
|
||||
use_albums_as_keywords=album_keyword,
|
||||
use_persons_as_keywords=person_keyword,
|
||||
keyword_template=keyword_template,
|
||||
description_template=description_template,
|
||||
update=update,
|
||||
export_db=export_db,
|
||||
fileutil=fileutil,
|
||||
@@ -2108,7 +2294,7 @@ def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
|
||||
dest_path = os.path.join(dest, dirname)
|
||||
if not is_valid_filepath(dest_path, platform="auto"):
|
||||
raise ValueError(f"Invalid file path: '{dest_path}'")
|
||||
if not (dry_run or os.path.isdir(dest_path)):
|
||||
if not dry_run and not os.path.isdir(dest_path):
|
||||
os.makedirs(dest_path)
|
||||
dest_paths.append(dest_path)
|
||||
else:
|
||||
@@ -2116,5 +2302,42 @@ def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
|
||||
return dest_paths
|
||||
|
||||
|
||||
def find_files_in_branch(pathname, filename):
|
||||
""" Search a directory branch to find file(s) named filename
|
||||
The branch searched includes all folders below pathname and
|
||||
the parent tree of pathname but not pathname itself.
|
||||
|
||||
e.g. find filename in children folders and parent folders
|
||||
|
||||
Args:
|
||||
pathname: str, full path of directory to search
|
||||
filename: str, filename to search for
|
||||
|
||||
Returns: list of full paths to any matching files
|
||||
"""
|
||||
|
||||
pathname = pathlib.Path(pathname).resolve()
|
||||
files = []
|
||||
|
||||
# walk down the tree
|
||||
for root, directories, filenames in os.walk(pathname):
|
||||
# for directory in directories:
|
||||
# print(os.path.join(root, directory))
|
||||
for fname in filenames:
|
||||
if fname == filename and pathlib.Path(root) != pathname:
|
||||
files.append(os.path.join(root, fname))
|
||||
|
||||
# walk up the tree
|
||||
path = pathlib.Path(pathname)
|
||||
for root in path.parents:
|
||||
filenames = os.listdir(root)
|
||||
for fname in filenames:
|
||||
filepath = os.path.join(root, fname)
|
||||
if fname == filename and os.path.isfile(filepath):
|
||||
files.append(os.path.join(root, fname))
|
||||
|
||||
return files
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli() # pylint: disable=no-value-for-parameter
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.29.14"
|
||||
__version__ = "0.30.10"
|
||||
|
||||
@@ -48,8 +48,16 @@ class AlbumInfo:
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
uuid = self._db._dbalbums_album[self._uuid]
|
||||
self._photos = self._db.photos(uuid=uuid)
|
||||
if self.uuid in self._db._dbalbums_album:
|
||||
uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid])
|
||||
self._photos = self._db.photos(uuid=uuid)
|
||||
# PhotosDB.photos does not preserve order when passing in list of uuids
|
||||
# so need to build photo list one a time
|
||||
# sort uuids by sort order
|
||||
sorted_uuid = sorted(zip(sort_order, uuid))
|
||||
self._photos = [self._db.photos(uuid=[uuid])[0] for _, uuid in sorted_uuid]
|
||||
else:
|
||||
self._photos = []
|
||||
return self._photos
|
||||
|
||||
@property
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -215,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)
|
||||
@@ -250,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
|
||||
"""
|
||||
|
||||
@@ -273,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
|
||||
@@ -297,6 +300,7 @@ 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,
|
||||
@@ -336,6 +340,7 @@ 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
|
||||
@@ -670,6 +675,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,
|
||||
)
|
||||
if not dry_run:
|
||||
try:
|
||||
@@ -685,6 +691,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,
|
||||
)
|
||||
if not dry_run:
|
||||
try:
|
||||
@@ -712,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:
|
||||
@@ -727,6 +735,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,
|
||||
)
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
@@ -734,6 +743,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,
|
||||
),
|
||||
)
|
||||
export_db.set_stat_exif_for_file(
|
||||
@@ -749,6 +759,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,
|
||||
)
|
||||
export_db.set_exifdata_for_file(
|
||||
exported_file,
|
||||
@@ -756,6 +767,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,
|
||||
),
|
||||
)
|
||||
export_db.set_stat_exif_for_file(
|
||||
@@ -955,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 """
|
||||
@@ -966,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():
|
||||
@@ -984,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
|
||||
@@ -1009,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
|
||||
|
||||
@@ -1082,7 +1103,6 @@ def _exiftool_json_sidecar(
|
||||
lat_str, lon_str = dd_to_dms_str(lat, lon)
|
||||
exif["EXIF:GPSLatitude"] = lat_str
|
||||
exif["EXIF:GPSLongitude"] = lon_str
|
||||
exif["Composite:GPSPosition"] = f"{lat_str}, {lon_str}"
|
||||
lat_ref = "North" if lat >= 0 else "South"
|
||||
lon_ref = "East" if lon >= 0 else "West"
|
||||
exif["EXIF:GPSLatitudeRef"] = lat_ref
|
||||
@@ -1112,16 +1132,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)
|
||||
@@ -1178,7 +1207,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
|
||||
|
||||
119
osxphotos/photoinfo/_photoinfo_scoreinfo.py
Normal 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
|
||||
@@ -21,12 +21,16 @@ 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,
|
||||
)
|
||||
from ..albuminfo import AlbumInfo
|
||||
from ..placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from ..phototemplate import PhotoTemplate
|
||||
from ..placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
|
||||
|
||||
|
||||
@@ -55,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
|
||||
@@ -64,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
|
||||
@@ -84,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):
|
||||
@@ -96,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
|
||||
|
||||
@@ -335,26 +339,31 @@ class PhotoInfo:
|
||||
@property
|
||||
def persons(self):
|
||||
""" list of persons in picture """
|
||||
return self._info["persons"]
|
||||
return [self._db._dbpersons_pk[k]["fullname"] for k in self._info["persons"]]
|
||||
|
||||
@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):
|
||||
@@ -408,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 """
|
||||
@@ -484,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 []
|
||||
|
||||
@@ -629,7 +642,49 @@ class PhotoInfo:
|
||||
otherwise returns False """
|
||||
return self._info["raw_is_original"]
|
||||
|
||||
def render_template(self, template_str, none_str="_", path_sep=None):
|
||||
@property
|
||||
def height(self):
|
||||
""" returns height of the current photo version in pixels """
|
||||
return self._info["height"]
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
""" returns width of the current photo version in pixels """
|
||||
return self._info["width"]
|
||||
|
||||
@property
|
||||
def orientation(self):
|
||||
""" returns EXIF orientation of the current photo version as int """
|
||||
return self._info["orientation"]
|
||||
|
||||
@property
|
||||
def original_height(self):
|
||||
""" returns height of the original photo version in pixels """
|
||||
return self._info["original_height"]
|
||||
|
||||
@property
|
||||
def original_width(self):
|
||||
""" returns width of the original photo version in pixels """
|
||||
return self._info["original_width"]
|
||||
|
||||
@property
|
||||
def original_orientation(self):
|
||||
""" returns EXIF orientation of the original photo version as int """
|
||||
return self._info["original_orientation"]
|
||||
|
||||
@property
|
||||
def original_filesize(self):
|
||||
""" returns filesize of original photo in bytes as int """
|
||||
return self._info["original_filesize"]
|
||||
|
||||
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
|
||||
|
||||
Args:
|
||||
@@ -637,9 +692,22 @@ class PhotoInfo:
|
||||
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, path_sep)
|
||||
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):
|
||||
@@ -651,6 +719,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})"
|
||||
|
||||
@@ -661,6 +760,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,
|
||||
@@ -701,6 +802,17 @@ 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,
|
||||
"height": self.height,
|
||||
"width": self.width,
|
||||
"orientation": self.orientation,
|
||||
"original_height": self.original_height,
|
||||
"original_width": self.original_width,
|
||||
"original_orientation": self.original_orientation,
|
||||
"original_filesize": self.original_filesize,
|
||||
}
|
||||
return yaml.dump(info, sort_keys=False)
|
||||
|
||||
@@ -713,6 +825,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,
|
||||
@@ -758,15 +871,30 @@ class PhotoInfo:
|
||||
"path_raw": self.path_raw,
|
||||
"place": place,
|
||||
"exif": exif,
|
||||
"score": score,
|
||||
"intrash": self.intrash,
|
||||
"height": self.height,
|
||||
"width": self.width,
|
||||
"orientation": self.orientation,
|
||||
"original_height": self.original_height,
|
||||
"original_width": self.original_width,
|
||||
"original_orientation": self.original_orientation,
|
||||
"original_filesize": self.original_filesize,
|
||||
}
|
||||
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)
|
||||
|
||||
145
osxphotos/photosdb/_photosdb_process_scoreinfo.py
Normal 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
|
||||
@@ -102,7 +102,7 @@ def _process_searchinfo(self):
|
||||
# 8: groups.lookup_identifier
|
||||
|
||||
for row in c:
|
||||
uuid = ints_to_uuid(row[1],row[2])
|
||||
uuid = ints_to_uuid(row[1], row[2])
|
||||
# strings have null character appended, so strip it
|
||||
record = {}
|
||||
record["uuid"] = uuid
|
||||
@@ -123,13 +123,9 @@ def _process_searchinfo(self):
|
||||
|
||||
category = record["category"]
|
||||
try:
|
||||
_db_searchinfo_categories[category].append(
|
||||
record["normalized_string"]
|
||||
)
|
||||
_db_searchinfo_categories[category].append(record["normalized_string"])
|
||||
except KeyError:
|
||||
_db_searchinfo_categories[category] = [
|
||||
record["normalized_string"]
|
||||
]
|
||||
_db_searchinfo_categories[category] = [record["normalized_string"]]
|
||||
|
||||
if category == SEARCH_CATEGORY_LABEL:
|
||||
label = record["content_string"]
|
||||
@@ -198,6 +194,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
|
||||
|
||||
@@ -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
|
||||
@@ -123,17 +122,28 @@ class PhotosDB:
|
||||
# currently used to get information on RAW images
|
||||
self._dbphotos_master = {}
|
||||
|
||||
# Dict with information about all persons by person PK
|
||||
# key is person PK, value is dict with info about each person
|
||||
# e.g. {3: {"pk": 3, "fullname": "Maria Smith"...}}
|
||||
self._dbpersons_pk = {}
|
||||
|
||||
# Dict with information about all persons by person fullname
|
||||
# key is person PK, value is list of person PKs with fullname
|
||||
# there may be more than one person PK with the same fullname
|
||||
# e.g. {"Maria Smith": [1, 2]}
|
||||
self._dbpersons_fullname = {}
|
||||
|
||||
# Dict with information about all persons/photos by uuid
|
||||
# key is photo UUID, value is list of face names in that photo
|
||||
# key is photo UUID, value is list of person primary keys of persons in the photo
|
||||
# Note: Photos 5 identifies faces even if not given a name
|
||||
# and those are labeled by process_database as _UNKNOWN_
|
||||
# e.g. {'1EB2B765-0765-43BA-A90C-0D0580E6172C': ['Katie', '_UNKNOWN_', 'Suzy']}
|
||||
# e.g. {'1EB2B765-0765-43BA-A90C-0D0580E6172C': [1, 3, 5]}
|
||||
self._dbfaces_uuid = {}
|
||||
|
||||
# Dict with information about all persons/photos by person
|
||||
# key is person name, value is list of photo UUIDs
|
||||
# e.g. {'Maria': ['E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51']}
|
||||
self._dbfaces_person = {}
|
||||
# Dict with information about detected faces by person primary key
|
||||
# key is person pk, value is list of photo UUIDs
|
||||
# e.g. {3: ['E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51']}
|
||||
self._dbfaces_pk = {}
|
||||
|
||||
# Dict with information about all keywords/photos by uuid
|
||||
# key is photo uuid and value is list of keywords
|
||||
@@ -157,8 +167,8 @@ class PhotosDB:
|
||||
self._dbalbums_pk = {}
|
||||
|
||||
# Dict with information about all albums/photos by album
|
||||
# key is album UUID, value is list of photo UUIDs contained in that album
|
||||
# e.g. {'0C514A98-7B77-4E4F-801B-364B7B65EAFA': ['1EB2B765-0765-43BA-A90C-0D0580E6172C']}
|
||||
# key is album UUID, value is list of tuples of (photo UUID, sort order) contained in that album
|
||||
# e.g. {'0C514A98-7B77-4E4F-801B-364B7B65EAFA': [('1EB2B765-0765-43BA-A90C-0D0580E6172C', 1024)]}
|
||||
self._dbalbums_album = {}
|
||||
|
||||
# Dict with information about album details
|
||||
@@ -254,7 +264,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):
|
||||
@@ -304,7 +314,13 @@ class PhotosDB:
|
||||
@property
|
||||
def persons_as_dict(self):
|
||||
""" return persons as dict of person, count in reverse sorted order (descending) """
|
||||
persons = {k: len(self._dbfaces_person[k]) for k in self._dbfaces_person.keys()}
|
||||
persons = {}
|
||||
for pk in self._dbfaces_pk:
|
||||
fullname = self._dbpersons_pk[pk]["fullname"]
|
||||
try:
|
||||
persons[fullname] += len(self._dbfaces_pk[pk])
|
||||
except KeyError:
|
||||
persons[fullname] = len(self._dbfaces_pk[pk])
|
||||
persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
|
||||
return persons
|
||||
|
||||
@@ -312,18 +328,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
|
||||
|
||||
@@ -332,25 +346,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
|
||||
|
||||
@@ -363,7 +369,7 @@ class PhotosDB:
|
||||
@property
|
||||
def persons(self):
|
||||
""" return list of persons found in photos database """
|
||||
persons = self._dbfaces_person.keys()
|
||||
persons = {self._dbpersons_pk[k]["fullname"] for k in self._dbfaces_pk}
|
||||
return list(persons)
|
||||
|
||||
@property
|
||||
@@ -411,32 +417,28 @@ class PhotosDB:
|
||||
@property
|
||||
def album_info(self):
|
||||
""" return list of AlbumInfo objects for each album in the photos database """
|
||||
|
||||
return [
|
||||
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"]
|
||||
]
|
||||
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 []
|
||||
|
||||
return [
|
||||
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"]
|
||||
]
|
||||
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):
|
||||
@@ -445,13 +447,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):
|
||||
@@ -463,19 +463,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):
|
||||
@@ -492,6 +484,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 """
|
||||
@@ -516,12 +516,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(
|
||||
@@ -547,41 +553,108 @@ class PhotosDB:
|
||||
|
||||
(conn, c) = _open_sql_file(self._tmp_db)
|
||||
|
||||
# Look for all combinations of persons and pictures
|
||||
# get info to associate persons with photos
|
||||
# then get detected faces in each photo and link to persons
|
||||
c.execute(
|
||||
""" select RKPerson.name, RKVersion.uuid from RKFace, RKPerson, RKVersion, RKMaster
|
||||
where RKFace.personID = RKperson.modelID and RKVersion.modelId = RKFace.ImageModelId
|
||||
and RKVersion.masterUuid = RKMaster.uuid
|
||||
and RKVersion.isInTrash = 0 """
|
||||
""" SELECT
|
||||
RKPerson.modelID,
|
||||
RKPerson.uuid,
|
||||
RKPerson.name,
|
||||
RKPerson.faceCount,
|
||||
RKPerson.displayName
|
||||
FROM RKPerson
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 RKPerson.modelID,
|
||||
# 1 RKPerson.uuid,
|
||||
# 2 RKPerson.name,
|
||||
# 3 RKPerson.faceCount,
|
||||
# 4 RKPerson.displayName
|
||||
|
||||
for person in c:
|
||||
if person[0] is None:
|
||||
continue
|
||||
if not person[1] in self._dbfaces_uuid:
|
||||
self._dbfaces_uuid[person[1]] = []
|
||||
if not person[0] in self._dbfaces_person:
|
||||
self._dbfaces_person[person[0]] = []
|
||||
self._dbfaces_uuid[person[1]].append(person[0])
|
||||
self._dbfaces_person[person[0]].append(person[1])
|
||||
pk = person[0]
|
||||
fullname = person[2] if person[2] is not None else _UNKNOWN_PERSON
|
||||
self._dbpersons_pk[pk] = {
|
||||
"pk": pk,
|
||||
"uuid": person[1],
|
||||
"fullname": fullname,
|
||||
"facecount": person[3],
|
||||
"keyface": None,
|
||||
"displayname": person[4],
|
||||
}
|
||||
try:
|
||||
self._dbpersons_fullname[fullname].append(pk)
|
||||
except KeyError:
|
||||
self._dbpersons_fullname[fullname] = [pk]
|
||||
|
||||
# get information on detected faces
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKPerson.modelID,
|
||||
RKVersion.uuid
|
||||
FROM
|
||||
RKFace, RKPerson, RKVersion, RKMaster
|
||||
WHERE
|
||||
RKFace.personID = RKperson.modelID AND
|
||||
RKVersion.modelId = RKFace.ImageModelId AND
|
||||
RKVersion.masterUuid = RKMaster.uuid
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 RKPerson.modelID
|
||||
# 1 RKVersion.uuid
|
||||
|
||||
for face in c:
|
||||
pk = face[0]
|
||||
uuid = face[1]
|
||||
try:
|
||||
self._dbfaces_uuid[uuid].append(pk)
|
||||
except KeyError:
|
||||
self._dbfaces_uuid[uuid] = [pk]
|
||||
|
||||
try:
|
||||
self._dbfaces_pk[pk].append(uuid)
|
||||
except KeyError:
|
||||
self._dbfaces_pk[pk] = [uuid]
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"Finished walking through persons")
|
||||
logging.debug(pformat(self._dbpersons_pk))
|
||||
logging.debug(pformat(self._dbpersons_fullname))
|
||||
logging.debug(pformat(self._dbfaces_pk))
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# Get info on albums
|
||||
c.execute(
|
||||
""" select
|
||||
""" SELECT
|
||||
RKAlbum.uuid,
|
||||
RKVersion.uuid
|
||||
from RKAlbum, RKVersion, RKAlbumVersion
|
||||
where RKAlbum.modelID = RKAlbumVersion.albumId and
|
||||
RKAlbumVersion.versionID = RKVersion.modelId
|
||||
and RKVersion.isInTrash = 0 """
|
||||
RKVersion.uuid,
|
||||
RKCustomSortOrder.orderNumber
|
||||
FROM RKVersion
|
||||
JOIN RKCustomSortOrder on RKCustomSortOrder.objectUuid = RKVersion.uuid
|
||||
JOIN RKAlbum on RKAlbum.uuid = RKCustomSortOrder.containerUuid
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 RKAlbum.uuid,
|
||||
# 1 RKVersion.uuid,
|
||||
# 2 RKCustomSortOrder.orderNumber
|
||||
|
||||
for album in c:
|
||||
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
|
||||
if not album[1] in self._dbalbums_uuid:
|
||||
self._dbalbums_uuid[album[1]] = []
|
||||
if not album[0] in self._dbalbums_album:
|
||||
self._dbalbums_album[album[0]] = []
|
||||
self._dbalbums_uuid[album[1]].append(album[0])
|
||||
self._dbalbums_album[album[0]].append(album[1])
|
||||
album_uuid = album[0]
|
||||
photo_uuid = album[1]
|
||||
sort_order = album[2]
|
||||
try:
|
||||
self._dbalbums_uuid[photo_uuid].append(album_uuid)
|
||||
except KeyError:
|
||||
self._dbalbums_uuid[photo_uuid] = [album_uuid]
|
||||
|
||||
try:
|
||||
self._dbalbums_album[album_uuid].append((photo_uuid, sort_order))
|
||||
except KeyError:
|
||||
self._dbalbums_album[album_uuid] = [(photo_uuid, sort_order)]
|
||||
|
||||
# now get additional details about albums
|
||||
c.execute(
|
||||
@@ -621,6 +694,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
|
||||
@@ -689,12 +764,17 @@ class PhotosDB:
|
||||
|
||||
# Get info on keywords
|
||||
c.execute(
|
||||
""" select RKKeyword.name, RKVersion.uuid, RKMaster.uuid from
|
||||
""" SELECT
|
||||
RKKeyword.name,
|
||||
RKVersion.uuid,
|
||||
RKMaster.uuid
|
||||
FROM
|
||||
RKKeyword, RKKeywordForVersion, RKVersion, RKMaster
|
||||
where RKKeyword.modelId = RKKeyWordForVersion.keywordID and
|
||||
RKVersion.modelID = RKKeywordForVersion.versionID and
|
||||
RKMaster.uuid = RKVersion.masterUuid and
|
||||
RKVersion.isInTrash = 0 """
|
||||
WHERE
|
||||
RKKeyword.modelId = RKKeyWordForVersion.keywordID AND
|
||||
RKVersion.modelID = RKKeywordForVersion.versionID AND
|
||||
RKMaster.uuid = RKVersion.masterUuid
|
||||
"""
|
||||
)
|
||||
for keyword in c:
|
||||
if not keyword[1] in self._dbkeywords_uuid:
|
||||
@@ -724,9 +804,17 @@ 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,
|
||||
RKVersion.processedHeight,
|
||||
RKVersion.processedWidth,
|
||||
RKVersion.orientation,
|
||||
RKMaster.height,
|
||||
RKMaster.width,
|
||||
RKMaster.orientation,
|
||||
RKMaster.fileSize
|
||||
FROM RKVersion, RKMaster
|
||||
WHERE RKVersion.masterUuid = RKMaster.uuid"""
|
||||
)
|
||||
else:
|
||||
c.execute(
|
||||
@@ -743,9 +831,17 @@ 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,
|
||||
RKVersion.processedHeight,
|
||||
RKVersion.processedWidth,
|
||||
RKVersion.orientation,
|
||||
RKMaster.height,
|
||||
RKMaster.width,
|
||||
RKMaster.orientation,
|
||||
RKMaster.originalFileSize
|
||||
FROM RKVersion, RKMaster
|
||||
WHERE RKVersion.masterUuid = RKMaster.uuid"""
|
||||
)
|
||||
|
||||
# order of results
|
||||
@@ -781,6 +877,14 @@ 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
|
||||
# 33 RKVersion.processedHeight,
|
||||
# 34 RKVersion.processedWidth,
|
||||
# 35 RKVersion.orientation,
|
||||
# 36 RKMaster.height,
|
||||
# 37 RKMaster.width,
|
||||
# 38 RKMaster.orientation,
|
||||
# 39 RKMaster.originalFileSize
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -807,7 +911,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]
|
||||
@@ -923,6 +1027,18 @@ 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
|
||||
|
||||
# height/width/orientation
|
||||
self._dbphotos[uuid]["height"] = row[33]
|
||||
self._dbphotos[uuid]["width"] = row[34]
|
||||
self._dbphotos[uuid]["orientation"] = row[35]
|
||||
self._dbphotos[uuid]["original_height"] = row[36]
|
||||
self._dbphotos[uuid]["original_width"] = row[37]
|
||||
self._dbphotos[uuid]["original_orientation"] = row[38]
|
||||
self._dbphotos[uuid]["original_filesize"] = row[39]
|
||||
|
||||
# get additional details from RKMaster, needed for RAW processing
|
||||
c.execute(
|
||||
""" SELECT
|
||||
@@ -973,8 +1089,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:
|
||||
@@ -1017,8 +1132,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:
|
||||
@@ -1040,8 +1154,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
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1205,8 +1317,8 @@ class PhotosDB:
|
||||
logging.debug("Faces (_dbfaces_uuid):")
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
logging.debug("Faces by person (_dbfaces_person):")
|
||||
logging.debug(pformat(self._dbfaces_person))
|
||||
logging.debug("Persons (_dbpersons_pk):")
|
||||
logging.debug(pformat(self._dbpersons_pk))
|
||||
|
||||
logging.debug("Keywords by uuid (_dbkeywords_uuid):")
|
||||
logging.debug(pformat(self._dbkeywords_uuid))
|
||||
@@ -1267,8 +1379,12 @@ class PhotosDB:
|
||||
return folders
|
||||
|
||||
def _process_database5(self):
|
||||
""" process the Photos database to extract info """
|
||||
""" works on Photos version >= 5.0 """
|
||||
""" process the Photos database to extract info
|
||||
works on Photos version >= 5.0
|
||||
|
||||
This is a big hairy 700 line function that should probably be refactored
|
||||
but it works so don't touch it.
|
||||
"""
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"_process_database5")
|
||||
@@ -1282,47 +1398,107 @@ class PhotosDB:
|
||||
if _debug():
|
||||
logging.debug(f"Getting information about persons")
|
||||
|
||||
# get info to associate persons with photos
|
||||
# then get detected faces in each photo and link to persons
|
||||
c.execute(
|
||||
"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"
|
||||
""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
ZPERSON.ZPERSONUUID,
|
||||
ZPERSON.ZFULLNAME,
|
||||
ZPERSON.ZFACECOUNT,
|
||||
ZPERSON.ZKEYFACE,
|
||||
ZPERSON.ZDISPLAYNAME
|
||||
FROM ZPERSON
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 ZPERSON.Z_PK,
|
||||
# 1 ZPERSON.ZPERSONUUID,
|
||||
# 2 ZPERSON.ZFULLNAME,
|
||||
# 3 ZPERSON.ZFACECOUNT,
|
||||
# 4 ZPERSON.ZKEYFACE,
|
||||
# 5 ZPERSON.ZDISPLAYNAME,
|
||||
|
||||
for person in c:
|
||||
if person[0] is None:
|
||||
continue
|
||||
person_name = person[0] if person[0] != "" else _UNKNOWN_PERSON
|
||||
if not person[1] in self._dbfaces_uuid:
|
||||
self._dbfaces_uuid[person[1]] = []
|
||||
if not person_name in self._dbfaces_person:
|
||||
self._dbfaces_person[person_name] = []
|
||||
self._dbfaces_uuid[person[1]].append(person_name)
|
||||
self._dbfaces_person[person_name].append(person[1])
|
||||
pk = person[0]
|
||||
fullname = person[2] if person[2] != "" else _UNKNOWN_PERSON
|
||||
self._dbpersons_pk[pk] = {
|
||||
"pk": pk,
|
||||
"uuid": person[1],
|
||||
"fullname": fullname,
|
||||
"facecount": person[3],
|
||||
"keyface": person[4],
|
||||
"displayname": person[5],
|
||||
}
|
||||
try:
|
||||
self._dbpersons_fullname[fullname].append(pk)
|
||||
except KeyError:
|
||||
self._dbpersons_fullname[fullname] = [pk]
|
||||
|
||||
# get information on detected faces
|
||||
c.execute(
|
||||
""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
ZGENERICASSET.ZUUID
|
||||
FROM ZPERSON, ZDETECTEDFACE, ZGENERICASSET
|
||||
WHERE ZDETECTEDFACE.ZPERSON = ZPERSON.Z_PK AND
|
||||
ZDETECTEDFACE.ZASSET = ZGENERICASSET.Z_PK;
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 ZPERSON.Z_PK,
|
||||
# 1 ZGENERICASSET.ZUUID,
|
||||
|
||||
for face in c:
|
||||
pk = face[0]
|
||||
uuid = face[1]
|
||||
try:
|
||||
self._dbfaces_uuid[uuid].append(pk)
|
||||
except KeyError:
|
||||
self._dbfaces_uuid[uuid] = [pk]
|
||||
|
||||
try:
|
||||
self._dbfaces_pk[pk].append(uuid)
|
||||
except KeyError:
|
||||
self._dbfaces_pk[pk] = [uuid]
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"Finished walking through persons")
|
||||
logging.debug(pformat(self._dbfaces_person))
|
||||
logging.debug(self._dbfaces_uuid)
|
||||
logging.debug(pformat(self._dbpersons_pk))
|
||||
logging.debug(pformat(self._dbpersons_fullname))
|
||||
logging.debug(pformat(self._dbfaces_pk))
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# get details about albums
|
||||
c.execute(
|
||||
"SELECT ZGENERICALBUM.ZUUID, ZGENERICASSET.ZUUID "
|
||||
"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 "
|
||||
""" SELECT
|
||||
ZGENERICALBUM.ZUUID,
|
||||
ZGENERICASSET.ZUUID,
|
||||
Z_26ASSETS.Z_FOK_34ASSETS
|
||||
FROM ZGENERICASSET
|
||||
JOIN Z_26ASSETS ON Z_26ASSETS.Z_34ASSETS = ZGENERICASSET.Z_PK
|
||||
JOIN ZGENERICALBUM ON ZGENERICALBUM.Z_PK = Z_26ASSETS.Z_26ALBUMS
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 ZGENERICALBUM.ZUUID,
|
||||
# 1 ZGENERICASSET.ZUUID,
|
||||
# 2 Z_26ASSETS.Z_FOK_34ASSETS
|
||||
|
||||
for album in c:
|
||||
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
|
||||
album_uuid = album[0]
|
||||
photo_uuid = album[1]
|
||||
sort_order = album[2]
|
||||
try:
|
||||
self._dbalbums_uuid[album[1]].append(album[0])
|
||||
self._dbalbums_uuid[photo_uuid].append(album_uuid)
|
||||
except KeyError:
|
||||
self._dbalbums_uuid[album[1]] = [album[0]]
|
||||
self._dbalbums_uuid[photo_uuid] = [album_uuid]
|
||||
|
||||
try:
|
||||
self._dbalbums_album[album[0]].append(album[1])
|
||||
self._dbalbums_album[album_uuid].append((photo_uuid, sort_order))
|
||||
except KeyError:
|
||||
self._dbalbums_album[album[0]] = [album[1]]
|
||||
self._dbalbums_album[album_uuid] = [(photo_uuid, sort_order)]
|
||||
|
||||
# now get additional details about albums
|
||||
c.execute(
|
||||
@@ -1412,7 +1588,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:
|
||||
@@ -1466,10 +1641,17 @@ class PhotosDB:
|
||||
ZGENERICASSET.ZCLOUDASSETGUID,
|
||||
ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA,
|
||||
ZGENERICASSET.ZMOMENT,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE,
|
||||
ZGENERICASSET.ZTRASHEDSTATE,
|
||||
ZGENERICASSET.ZHEIGHT,
|
||||
ZGENERICASSET.ZWIDTH,
|
||||
ZGENERICASSET.ZORIENTATION,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALHEIGHT,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
|
||||
FROM ZGENERICASSET
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
|
||||
WHERE ZGENERICASSET.ZTRASHEDSTATE = 0
|
||||
ORDER BY ZGENERICASSET.ZUUID """
|
||||
)
|
||||
# Order of results
|
||||
@@ -1502,6 +1684,14 @@ 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
|
||||
# 29 ZGENERICASSET.ZHEIGHT,
|
||||
# 30 ZGENERICASSET.ZWIDTH,
|
||||
# 31 ZGENERICASSET.ZORIENTATION,
|
||||
# 32 ZADDITIONALASSETATTRIBUTES.ZORIGINALHEIGHT,
|
||||
# 33 ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
|
||||
# 34 ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
|
||||
# 35 ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -1525,7 +1715,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]
|
||||
@@ -1650,6 +1840,18 @@ 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
|
||||
|
||||
# height/width/orientation
|
||||
info["height"] = row[29]
|
||||
info["width"] = row[30]
|
||||
info["orientation"] = row[31]
|
||||
info["original_height"] = row[32]
|
||||
info["original_width"] = row[33]
|
||||
info["original_orientation"] = row[34]
|
||||
info["original_filesize"] = row[35]
|
||||
|
||||
# associated RAW image info
|
||||
# will be filled in later
|
||||
info["has_raw"] = False
|
||||
@@ -1703,7 +1905,6 @@ class PhotosDB:
|
||||
"FROM ZGENERICASSET, ZUNMANAGEDADJUSTMENT "
|
||||
"JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK "
|
||||
"WHERE ZADDITIONALASSETATTRIBUTES.ZUNMANAGEDADJUSTMENT = ZUNMANAGEDADJUSTMENT.Z_PK "
|
||||
"AND ZGENERICASSET.ZTRASHEDSTATE = 0 "
|
||||
)
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -1862,13 +2063,16 @@ 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):")
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
logging.debug("Faces by person (_dbfaces_person):")
|
||||
logging.debug(pformat(self._dbfaces_person))
|
||||
logging.debug("Persons (_dbpersons_pk):")
|
||||
logging.debug(pformat(self._dbpersons_pk))
|
||||
|
||||
logging.debug("Keywords by uuid (_dbkeywords_uuid):")
|
||||
logging.debug(pformat(self._dbkeywords_uuid))
|
||||
@@ -2082,6 +2286,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,
|
||||
@@ -2089,9 +2352,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
|
||||
@@ -2105,9 +2369,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
|
||||
@@ -2115,6 +2381,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
|
||||
@@ -2123,13 +2398,14 @@ 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]:
|
||||
try:
|
||||
title_set.update(self._dbalbums_album[album_id])
|
||||
# _dbalbums_album value is list of tuples: [(uuid, sort order)]
|
||||
uuid_in_album, _ = zip(*self._dbalbums_album[album_id])
|
||||
title_set.update(uuid_in_album)
|
||||
except KeyError:
|
||||
# an empty album will be in _dbalbum_titles but not _dbalbums_album
|
||||
pass
|
||||
@@ -2159,13 +2435,14 @@ class PhotosDB:
|
||||
if persons:
|
||||
person_set = set()
|
||||
for person in persons:
|
||||
if person in self._dbfaces_person:
|
||||
person_set.update(self._dbfaces_person[person])
|
||||
if person in self._dbpersons_fullname:
|
||||
for pk in self._dbpersons_fullname[person]:
|
||||
person_set.update(self._dbfaces_pk[pk])
|
||||
else:
|
||||
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 = {
|
||||
@@ -2182,7 +2459,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"]:
|
||||
@@ -2211,5 +2487,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)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
# 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
|
||||
@@ -35,6 +36,14 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{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",
|
||||
@@ -43,6 +52,31 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{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",
|
||||
@@ -86,16 +120,41 @@ class PhotoTemplate:
|
||||
"""
|
||||
self.photo = photo
|
||||
|
||||
def render(self, template, none_str="_", path_sep=None):
|
||||
""" render a filename or directory template
|
||||
# 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 """
|
||||
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
|
||||
@@ -107,7 +166,7 @@ class PhotoTemplate:
|
||||
# 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\-. ]+))?)(?=\}(?!\}))\}"
|
||||
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-\%. ]+))?)(?=\}(?!\}))\}"
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
@@ -122,7 +181,7 @@ class PhotoTemplate:
|
||||
groups = len(matchobj.groups())
|
||||
if groups == 4:
|
||||
try:
|
||||
val = get_func(matchobj.group(1))
|
||||
val = get_func(matchobj.group(1), matchobj.group(3))
|
||||
except ValueError:
|
||||
return matchobj.group(0)
|
||||
|
||||
@@ -172,7 +231,7 @@ class PhotoTemplate:
|
||||
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,})))?\}"
|
||||
re_str = r"(?<!\\)\{(" + field + r")(,(([\w\-\%. ]{0,})))?\}"
|
||||
regex_multi = re.compile(re_str)
|
||||
|
||||
# holds each of the new rendered_strings, set() to avoid duplicates
|
||||
@@ -181,12 +240,19 @@ class PhotoTemplate:
|
||||
for str_template in rendered_strings:
|
||||
if regex_multi.search(str_template):
|
||||
values = self.get_template_value_multi(field, path_sep)
|
||||
for val in values:
|
||||
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):
|
||||
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 """
|
||||
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:
|
||||
@@ -196,10 +262,33 @@ class PhotoTemplate:
|
||||
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
|
||||
# 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 = []
|
||||
@@ -220,11 +309,12 @@ class PhotoTemplate:
|
||||
|
||||
return rendered_strings, unmatched
|
||||
|
||||
def get_template_value(self, field):
|
||||
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).
|
||||
@@ -233,6 +323,10 @@ class PhotoTemplate:
|
||||
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
|
||||
@@ -273,6 +367,24 @@ class PhotoTemplate:
|
||||
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
|
||||
@@ -329,6 +441,83 @@ class PhotoTemplate:
|
||||
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
|
||||
|
||||
|
||||
@@ -503,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()
|
||||
|
||||
@@ -535,16 +534,23 @@ class PlaceInfo5(PlaceInfo):
|
||||
@property
|
||||
def address(self):
|
||||
addr = self._plrevgeoloc.postalAddress
|
||||
return 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,
|
||||
)
|
||||
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 """
|
||||
@@ -632,5 +638,5 @@ class PlaceInfo5(PlaceInfo):
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -71,29 +71,42 @@
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="gps_info(latitude, longitude)">
|
||||
% if latitude is not None and longitude is not None:
|
||||
<exif:GPSLongitudeRef>${"E" if longitude >= 0 else "W"}</exif:GPSLongitudeRef>
|
||||
<exif:GPSLongitude>${abs(longitude)}</exif:GPSLongitude>
|
||||
<exif:GPSLatitude>${abs(latitude)}</exif:GPSLatitude>
|
||||
<exif:GPSLatitudeRef>${"N" if latitude >= 0 else "S"}</exif:GPSLatitudeRef>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
|
||||
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||
${dc_description(photo.description)}
|
||||
${dc_description(description)}
|
||||
${dc_title(photo.title)}
|
||||
${dc_subject(subjects)}
|
||||
${dc_datecreated(photo.date)}
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
${iptc_personinimage(persons)}
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
${dk_tagslist(keywords)}
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
${adobe_createdate(photo.date)}
|
||||
${adobe_modifydate(photo.date)}
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
${gps_info(*photo.location)}
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
@@ -149,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:
|
||||
@@ -166,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():
|
||||
@@ -194,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
|
||||
|
||||
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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-07-06T16:39:04Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-04-25T23:54:29Z</date>
|
||||
<date>2020-07-06T16:39:09Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>606</integer>
|
||||
<integer>664</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 186 KiB |
@@ -9,7 +9,7 @@
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>606</integer>
|
||||
<integer>664</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
@@ -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-07-06T16:39:02Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>703</integer>
|
||||
<integer>1743</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -10,6 +10,7 @@
|
||||
<string>88A5F8B8-5B9A-43C7-BB85-3952B81580EB/L0/020</string>
|
||||
<string>29EF7A97-7E76-4D5F-A5E0-CC0A93E8524C/L0/020</string>
|
||||
<string>2C2AF115-BD1D-4434-A747-D1C8BD8E2045/L0/020</string>
|
||||
<string>CB051A4C-2CB7-4B90-B59B-08CC4D0C2823/L0/020</string>
|
||||
</array>
|
||||
<key>Photos</key>
|
||||
<dict>
|
||||
|
||||
@@ -3,24 +3,24 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BackgroundHighlightCollection</key>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2020-06-06T14:26:29Z</date>
|
||||
<date>2020-06-24T04:02:12Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2020-06-06T14:26:29Z</date>
|
||||
<date>2020-06-24T04:02:12Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-05-30T02:16:06Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-05-29T04:31:37Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-06-06T14:26:33Z</date>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2020-06-06T14:26:31Z</date>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -1,7 +1,6 @@
|
||||
import pytest
|
||||
|
||||
# TODO: put some of this code into a pre-function
|
||||
# TODO: All the hardocded uuids, etc in test functions should be in some sort of config
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.12.6.photoslibrary/database/photos.db"
|
||||
KEYWORDS = [
|
||||
@@ -15,8 +14,8 @@ KEYWORDS = [
|
||||
"UK",
|
||||
"United Kingdom",
|
||||
]
|
||||
PERSONS = ["Katie", "Suzy", "Maria"]
|
||||
ALBUMS = ["Pumpkin Farm", "Last Import", "AlbumInFolder"]
|
||||
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
|
||||
ALBUMS = ["Pumpkin Farm", "AlbumInFolder"]
|
||||
KEYWORDS_DICT = {
|
||||
"Kids": 4,
|
||||
"wedding": 2,
|
||||
@@ -28,8 +27,8 @@ KEYWORDS_DICT = {
|
||||
"UK": 1,
|
||||
"United Kingdom": 1,
|
||||
}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
|
||||
ALBUM_DICT = {"Pumpkin Farm": 3, "Last Import": 1, "AlbumInFolder": 1}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1, _UNKNOWN_PERSON: 1}
|
||||
ALBUM_DICT = {"Pumpkin Farm": 3, "AlbumInFolder": 1}
|
||||
|
||||
|
||||
def test_init():
|
||||
@@ -124,7 +123,7 @@ def test_attributes():
|
||||
)
|
||||
assert p.description == "Girl holding pumpkin"
|
||||
assert p.title == "I found one!"
|
||||
assert p.albums == ["Pumpkin Farm", "AlbumInFolder"]
|
||||
assert sorted(p.albums) == ["AlbumInFolder", "Pumpkin Farm"]
|
||||
assert p.persons == ["Katie"]
|
||||
assert p.path.endswith(
|
||||
"/tests/Test-10.12.6.photoslibrary/Masters/2019/08/24/20190824-030824/Pumkins2.jpg"
|
||||
|
||||
@@ -229,6 +229,7 @@ def test_albums_photos():
|
||||
|
||||
|
||||
def test_photoinfo_albums():
|
||||
""" Test PhotoInfo.albums """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
@@ -238,7 +239,20 @@ def test_photoinfo_albums():
|
||||
assert "Pumpkin Farm" in albums
|
||||
|
||||
|
||||
def test_photoinfo_albums_2():
|
||||
""" Test that PhotoInfo.albums returns only number albums expected """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["two_albums"]])
|
||||
|
||||
albums = photos[0].albums
|
||||
assert len(albums) == 2
|
||||
|
||||
|
||||
def test_photoinfo_album_info():
|
||||
""" test PhotoInfo.album_info """
|
||||
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
@@ -249,4 +263,4 @@ def test_photoinfo_album_info():
|
||||
assert album_info[0].title in ["Pumpkin Farm", "Test Album"]
|
||||
assert album_info[1].title in ["Pumpkin Farm", "Test Album"]
|
||||
|
||||
assert photos[0] in album_info[0].photos
|
||||
assert photos[0].uuid in [photo.uuid for photo in album_info[0].photos]
|
||||
|
||||
@@ -244,4 +244,4 @@ def test_photoinfo_album_info():
|
||||
assert album_info[0].title in ["Pumpkin Farm", "Test Album"]
|
||||
assert album_info[1].title in ["Pumpkin Farm", "Test Album"]
|
||||
|
||||
assert photos[0] in album_info[0].photos
|
||||
assert photos[0].uuid in [photo.uuid for photo in album_info[0].photos]
|
||||
|
||||
@@ -106,6 +106,7 @@ def test_init4():
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def test_init5(mocker):
|
||||
# test failed get_last_library_path
|
||||
import osxphotos
|
||||
@@ -116,7 +117,6 @@ def test_init5(mocker):
|
||||
# get_last_library actually in utils but need to patch it in photosdb because it's imported into photosdb
|
||||
# because of the layout of photosdb/ need to patch it this way...don't really understand why, but it works
|
||||
mocker.patch("osxphotos.photosdb.photosdb.get_last_library_path", new=bad_library)
|
||||
|
||||
|
||||
with pytest.raises(Exception):
|
||||
assert osxphotos.PhotosDB()
|
||||
@@ -207,7 +207,7 @@ def test_attributes():
|
||||
)
|
||||
assert p.description == "Girl holding pumpkin"
|
||||
assert p.title == "I found one!"
|
||||
assert p.albums == ["Pumpkin Farm", "Test Album", "Multi Keyword"]
|
||||
assert sorted(p.albums) == ["Multi Keyword", "Pumpkin Farm", "Test Album"]
|
||||
assert p.persons == ["Katie"]
|
||||
assert p.path.endswith(
|
||||
"tests/Test-10.15.1.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg"
|
||||
|
||||
@@ -215,7 +215,7 @@ def test_attributes():
|
||||
)
|
||||
assert p.description == "Girl holding pumpkin"
|
||||
assert p.title == "I found one!"
|
||||
assert p.albums == ["Pumpkin Farm", "Test Album"]
|
||||
assert sorted(p.albums) == ["Pumpkin Farm", "Test Album"]
|
||||
assert p.persons == ["Katie"]
|
||||
assert p.path.endswith(
|
||||
"tests/Test-10.15.4.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg"
|
||||
|
||||
@@ -7,7 +7,9 @@ PHOTOS_DB = "tests/Test-10.15.5.photoslibrary/database/photos.db"
|
||||
PHOTOS_DB_PATH = "/Test-10.15.5.photoslibrary/database/photos.db"
|
||||
PHOTOS_LIBRARY_PATH = "/Test-10.15.5.photoslibrary"
|
||||
|
||||
PHOTOS_DB_LEN = 13
|
||||
PHOTOS_DB_LEN = 15
|
||||
PHOTOS_NOT_IN_TRASH_LEN = 13
|
||||
PHOTOS_IN_TRASH_LEN = 2
|
||||
|
||||
KEYWORDS = [
|
||||
"Kids",
|
||||
@@ -28,10 +30,11 @@ ALBUMS = [
|
||||
"AlbumInFolder",
|
||||
"Raw",
|
||||
"I have a deleted twin", # there's an empty album with same name that has been deleted
|
||||
"EmptyAlbum",
|
||||
]
|
||||
KEYWORDS_DICT = {
|
||||
"Kids": 4,
|
||||
"wedding": 2,
|
||||
"wedding": 3,
|
||||
"flowers": 1,
|
||||
"England": 1,
|
||||
"London": 1,
|
||||
@@ -40,13 +43,14 @@ KEYWORDS_DICT = {
|
||||
"UK": 1,
|
||||
"United Kingdom": 1,
|
||||
}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1, _UNKNOWN_PERSON: 1}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 2, _UNKNOWN_PERSON: 1}
|
||||
ALBUM_DICT = {
|
||||
"Pumpkin Farm": 3,
|
||||
"Test Album": 2,
|
||||
"AlbumInFolder": 2,
|
||||
"Raw": 4,
|
||||
"I have a deleted twin": 1,
|
||||
"EmptyAlbum": 0,
|
||||
} # Note: there are 2 albums named "Test Album" for testing duplicate album names
|
||||
|
||||
UUID_DICT = {
|
||||
@@ -63,6 +67,11 @@ UUID_DICT = {
|
||||
"no_external_edit": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51",
|
||||
"export": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg"
|
||||
"export_tif": "8846E3E6-8AC8-4857-8448-E3D025784410",
|
||||
"in_album": "D79B8D77-BFFC-460B-9312-034F2877D35B", # "Pumkins2.jpg"
|
||||
"date_invalid": "8846E3E6-8AC8-4857-8448-E3D025784410",
|
||||
"intrash": "71E3E212-00EB-430D-8A63-5E294B268554",
|
||||
"not_intrash": "DC99FBDD-7A52-4100-A5BB-344131646C30",
|
||||
"intrash_person_keywords": "6FD38366-3BF2-407D-81FE-7153EB6125B6",
|
||||
}
|
||||
|
||||
UUID_PUMPKIN_FARM = [
|
||||
@@ -71,6 +80,12 @@ UUID_PUMPKIN_FARM = [
|
||||
"1EB2B765-0765-43BA-A90C-0D0580E6172C",
|
||||
]
|
||||
|
||||
ALBUM_SORT_ORDER = [
|
||||
"1EB2B765-0765-43BA-A90C-0D0580E6172C",
|
||||
"F12384F6-CD17-4151-ACBA-AE0E3688539E",
|
||||
"D79B8D77-BFFC-460B-9312-034F2877D35B",
|
||||
]
|
||||
ALBUM_KEY_PHOTO = "D79B8D77-BFFC-460B-9312-034F2877D35B"
|
||||
|
||||
def test_init1():
|
||||
# test named argument
|
||||
@@ -187,7 +202,7 @@ def test_keywords_dict():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
keywords = photosdb.keywords_as_dict
|
||||
assert keywords["wedding"] == 2
|
||||
assert keywords["wedding"] == 3
|
||||
assert keywords == KEYWORDS_DICT
|
||||
|
||||
|
||||
@@ -196,7 +211,7 @@ def test_persons_as_dict():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
persons = photosdb.persons_as_dict
|
||||
assert persons["Maria"] == 1
|
||||
assert persons["Maria"] == 2
|
||||
assert persons == PERSONS_DICT
|
||||
|
||||
|
||||
@@ -208,6 +223,23 @@ def test_albums_as_dict():
|
||||
assert albums["Pumpkin Farm"] == 3
|
||||
assert albums == ALBUM_DICT
|
||||
|
||||
def test_album_sort_order():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
album = [a for a in photosdb.album_info if a.title == "Pumpkin Farm"][0]
|
||||
photos = album.photos
|
||||
|
||||
uuids = [p.uuid for p in photos]
|
||||
assert uuids == ALBUM_SORT_ORDER
|
||||
|
||||
def test_album_empty_album():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
album = [a for a in photosdb.album_info if a.title == "EmptyAlbum"][0]
|
||||
photos = album.photos
|
||||
assert photos == []
|
||||
|
||||
def test_attributes():
|
||||
import datetime
|
||||
@@ -225,7 +257,7 @@ def test_attributes():
|
||||
)
|
||||
assert p.description == "Girl holding pumpkin"
|
||||
assert p.title == "I found one!"
|
||||
assert p.albums == ["Pumpkin Farm", "Test Album"]
|
||||
assert sorted(p.albums) == ["Pumpkin Farm", "Test Album"]
|
||||
assert p.persons == ["Katie"]
|
||||
assert p.path.endswith(
|
||||
"tests/Test-10.15.5.photoslibrary/originals/D/D79B8D77-BFFC-460B-9312-034F2877D35B.jpeg"
|
||||
@@ -233,6 +265,46 @@ def test_attributes():
|
||||
assert p.ismissing == False
|
||||
|
||||
|
||||
def test_attributes_2():
|
||||
""" Test attributes including height, width, etc """
|
||||
import datetime
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
|
||||
assert len(photos) == 1
|
||||
p = photos[0]
|
||||
assert p.keywords == ["wedding"]
|
||||
assert p.original_filename == "wedding.jpg"
|
||||
assert p.filename == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg"
|
||||
assert p.date == datetime.datetime(
|
||||
2019,
|
||||
4,
|
||||
15,
|
||||
14,
|
||||
40,
|
||||
24,
|
||||
86000,
|
||||
datetime.timezone(datetime.timedelta(seconds=-14400)),
|
||||
)
|
||||
assert p.description == "Bride Wedding day"
|
||||
assert p.title is None
|
||||
assert sorted(p.albums) == ["AlbumInFolder", "I have a deleted twin"]
|
||||
assert p.persons == ["Maria"]
|
||||
assert p.path.endswith(
|
||||
"tests/Test-10.15.5.photoslibrary/originals/E/E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51.jpeg"
|
||||
)
|
||||
assert not p.ismissing
|
||||
assert p.hasadjustments
|
||||
assert p.height == 1325
|
||||
assert p.width == 1526
|
||||
assert p.original_height == 1367
|
||||
assert p.original_width == 2048
|
||||
assert p.orientation == 1
|
||||
assert p.original_orientation == 1
|
||||
assert p.original_filesize == 460483
|
||||
|
||||
|
||||
def test_missing():
|
||||
import osxphotos
|
||||
|
||||
@@ -389,7 +461,96 @@ def test_count():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos()
|
||||
assert len(photos) == PHOTOS_DB_LEN
|
||||
assert len(photos) == PHOTOS_NOT_IN_TRASH_LEN
|
||||
|
||||
|
||||
def test_photos_intrash_1():
|
||||
""" test PhotosDB.photos(intrash=True) """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(intrash=True)
|
||||
assert len(photos) == PHOTOS_IN_TRASH_LEN
|
||||
|
||||
|
||||
def test_photos_intrash_2():
|
||||
""" test PhotosDB.photos(intrash=True) """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(intrash=True)
|
||||
for p in photos:
|
||||
assert p.intrash
|
||||
|
||||
|
||||
def test_photos_intrash_3():
|
||||
""" test PhotosDB.photos(intrash=False) """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(intrash=False)
|
||||
for p in photos:
|
||||
assert not p.intrash
|
||||
|
||||
|
||||
def test_photoinfo_intrash_1():
|
||||
""" Test PhotoInfo.intrash """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
p = photosdb.photos(uuid=[UUID_DICT["intrash"]], intrash=True)[0]
|
||||
assert p.intrash
|
||||
|
||||
|
||||
def test_photoinfo_intrash_2():
|
||||
""" Test PhotoInfo.intrash and intrash=default"""
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
p = photosdb.photos(uuid=[UUID_DICT["intrash"]])
|
||||
assert not p
|
||||
|
||||
|
||||
def test_photoinfo_intrash_3():
|
||||
""" Test PhotoInfo.intrash and photo has keyword and person """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
p = photosdb.photos(uuid=[UUID_DICT["intrash_person_keywords"]], intrash=True)[0]
|
||||
assert p.intrash
|
||||
assert "Maria" in p.persons
|
||||
assert "wedding" in p.keywords
|
||||
|
||||
|
||||
def test_photoinfo_intrash_4():
|
||||
""" Test PhotoInfo.intrash and photo has keyword and person """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
p = photosdb.photos(persons=["Maria"], intrash=True)[0]
|
||||
assert p.intrash
|
||||
assert "Maria" in p.persons
|
||||
assert "wedding" in p.keywords
|
||||
|
||||
|
||||
def test_photoinfo_intrash_5():
|
||||
""" Test PhotoInfo.intrash and photo has keyword and person """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
p = photosdb.photos(keywords=["wedding"], intrash=True)[0]
|
||||
assert p.intrash
|
||||
assert "Maria" in p.persons
|
||||
assert "wedding" in p.keywords
|
||||
|
||||
|
||||
def test_photoinfo_not_intrash():
|
||||
""" Test PhotoInfo.intrash """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
p = photosdb.photos(uuid=[UUID_DICT["not_intrash"]])[0]
|
||||
assert not p.intrash
|
||||
|
||||
|
||||
def test_keyword_2():
|
||||
@@ -397,7 +558,7 @@ def test_keyword_2():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(keywords=["wedding"])
|
||||
assert len(photos) == 2
|
||||
assert len(photos) == 2 # won't show the one in the trash
|
||||
|
||||
|
||||
def test_keyword_not_in_album():
|
||||
@@ -422,7 +583,14 @@ def test_album_folder_name():
|
||||
photos = photosdb.photos(albums=["Pumpkin Farm"])
|
||||
assert sorted(p.uuid for p in photos) == sorted(UUID_PUMPKIN_FARM)
|
||||
|
||||
def test_multi_person():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
||||
photos = photosdb.photos(persons=["Katie", "Suzy"])
|
||||
|
||||
assert len(photos) == 3
|
||||
|
||||
def test_get_db_path():
|
||||
import osxphotos
|
||||
|
||||
@@ -439,6 +607,26 @@ def test_get_library_path():
|
||||
assert lib_path.endswith(PHOTOS_LIBRARY_PATH)
|
||||
|
||||
|
||||
def test_get_db_connection():
|
||||
""" Test PhotosDB.get_db_connection """
|
||||
import osxphotos
|
||||
import sqlite3
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
conn, cursor = photosdb.get_db_connection()
|
||||
|
||||
assert isinstance(conn, sqlite3.Connection)
|
||||
assert isinstance(cursor, sqlite3.Cursor)
|
||||
|
||||
results = conn.execute(
|
||||
"SELECT ZUUID FROM ZGENERICASSET WHERE ZFAVORITE = 1;"
|
||||
).fetchall()
|
||||
assert len(results) == 1
|
||||
assert results[0][0] == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51" # uuid
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_export_1():
|
||||
# test basic export
|
||||
# get an unedited image and export it using default filename
|
||||
@@ -777,11 +965,29 @@ def test_export_14(caplog):
|
||||
|
||||
|
||||
def test_eq():
|
||||
""" Test equality of two PhotoInfo objects """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos1 = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
photos2 = photosdb.photos(uuid=[UUID_DICT["export"]])
|
||||
photosdb1 = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photosdb2 = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos1 = photosdb1.photos(uuid=[UUID_DICT["export"]])
|
||||
photos2 = photosdb2.photos(uuid=[UUID_DICT["export"]])
|
||||
assert photos1[0] == photos2[0]
|
||||
|
||||
|
||||
def test_eq_2():
|
||||
""" Test equality of two PhotoInfo objects when one has memoized property """
|
||||
import osxphotos
|
||||
|
||||
photosdb1 = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photosdb2 = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos1 = photosdb1.photos(uuid=[UUID_DICT["in_album"]])
|
||||
photos2 = photosdb2.photos(uuid=[UUID_DICT["in_album"]])
|
||||
|
||||
# memoize a value
|
||||
albums = photos1[0].albums
|
||||
assert albums
|
||||
|
||||
assert photos1[0] == photos2[0]
|
||||
|
||||
|
||||
@@ -827,12 +1033,39 @@ def test_from_to_date():
|
||||
photosdb = osxphotos.PhotosDB(PHOTOS_DB)
|
||||
|
||||
photos = photosdb.photos(from_date=dt.datetime(2018, 10, 28))
|
||||
assert len(photos) == 7
|
||||
assert len(photos) == 6
|
||||
|
||||
photos = photosdb.photos(to_date=dt.datetime(2018, 10, 28))
|
||||
assert len(photos) == 6
|
||||
assert len(photos) == 7
|
||||
|
||||
photos = photosdb.photos(
|
||||
from_date=dt.datetime(2018, 9, 28), to_date=dt.datetime(2018, 9, 29)
|
||||
)
|
||||
assert len(photos) == 4
|
||||
|
||||
|
||||
def test_date_invalid():
|
||||
""" Test date is invalid """
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["date_invalid"]])
|
||||
assert len(photos) == 1
|
||||
p = photos[0]
|
||||
delta = timedelta(seconds=p.tzoffset)
|
||||
tz = timezone(delta)
|
||||
assert p.date == datetime(1970, 1, 1).astimezone(tz=tz)
|
||||
|
||||
|
||||
def test_date_modified_invalid():
|
||||
""" Test date modified is invalid """
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["date_invalid"]])
|
||||
assert len(photos) == 1
|
||||
p = photos[0]
|
||||
assert p.date_modified is None
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ CLI_OUTPUT_NO_SUBCOMMAND = [
|
||||
" 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.",
|
||||
@@ -196,6 +197,23 @@ CLI_EXPORT_RAW_EDITED = [
|
||||
]
|
||||
CLI_EXPORT_RAW_EDITED_ORIGINAL = ["IMG_0476_2.CR2", "IMG_0476_2_edited.jpeg"]
|
||||
|
||||
CLI_UUID_DICT_15_5 = {
|
||||
"intrash": "71E3E212-00EB-430D-8A63-5E294B268554",
|
||||
"template": "F12384F6-CD17-4151-ACBA-AE0E3688539E",
|
||||
}
|
||||
|
||||
CLI_TEMPLATE_SIDECAR_FILENAME = "Pumkins1.json"
|
||||
|
||||
CLI_UUID_DICT_14_6 = {"intrash": "3tljdX43R8+k6peNHVrJNQ"}
|
||||
|
||||
PHOTOS_NOT_IN_TRASH_LEN_14_6 = 7
|
||||
PHOTOS_IN_TRASH_LEN_14_6 = 1
|
||||
PHOTOS_MISSING_14_6 = 1
|
||||
|
||||
PHOTOS_NOT_IN_TRASH_LEN_15_5 = 13
|
||||
PHOTOS_IN_TRASH_LEN_15_5 = 2
|
||||
PHOTOS_MISSING_15_5 = 2
|
||||
|
||||
CLI_PLACES_JSON = """{"places": {"_UNKNOWN_": 1, "Maui, Wailea, Hawai'i, United States": 1, "Washington, District of Columbia, United States": 1}}"""
|
||||
|
||||
CLI_EXIFTOOL = {
|
||||
@@ -210,6 +228,63 @@ CLI_EXIFTOOL = {
|
||||
"XMP:Subject": ["Kids", "Katie"],
|
||||
}
|
||||
}
|
||||
|
||||
LABELS_JSON = {
|
||||
"labels": {
|
||||
"Plant": 5,
|
||||
"Tree": 2,
|
||||
"Sky": 2,
|
||||
"Outdoor": 2,
|
||||
"Art": 2,
|
||||
"Foliage": 2,
|
||||
"Waterways": 1,
|
||||
"River": 1,
|
||||
"Cloudy": 1,
|
||||
"Land": 1,
|
||||
"Water Body": 1,
|
||||
"Water": 1,
|
||||
"Statue": 1,
|
||||
"Window": 1,
|
||||
"Decorative Plant": 1,
|
||||
"Blue Sky": 1,
|
||||
"Palm Tree": 1,
|
||||
"Flower": 1,
|
||||
"Flower Arrangement": 1,
|
||||
"Bouquet": 1,
|
||||
"Vase": 1,
|
||||
"Container": 1,
|
||||
"Camera": 1,
|
||||
}
|
||||
}
|
||||
|
||||
KEYWORDS_JSON = {
|
||||
"keywords": {
|
||||
"Kids": 4,
|
||||
"wedding": 3,
|
||||
"London 2018": 1,
|
||||
"St. James's Park": 1,
|
||||
"England": 1,
|
||||
"United Kingdom": 1,
|
||||
"UK": 1,
|
||||
"London": 1,
|
||||
"flowers": 1,
|
||||
}
|
||||
}
|
||||
|
||||
ALBUMS_JSON = {
|
||||
"albums": {
|
||||
"Raw": 4,
|
||||
"Pumpkin Farm": 3,
|
||||
"AlbumInFolder": 2,
|
||||
"Test Album": 2,
|
||||
"I have a deleted twin": 1,
|
||||
"EmptyAlbum": 0,
|
||||
},
|
||||
"shared albums": {},
|
||||
}
|
||||
|
||||
PERSONS_JSON = {"persons": {"Katie": 3, "Suzy": 2, "_UNKNOWN_": 1, "Maria": 2}}
|
||||
|
||||
# determine if exiftool installed so exiftool tests can be skipped
|
||||
try:
|
||||
exiftool = get_exiftool_path()
|
||||
@@ -224,9 +299,10 @@ def test_osxphotos():
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
output = result.output
|
||||
|
||||
assert result.exit_code == 0
|
||||
for line in CLI_OUTPUT_NO_SUBCOMMAND:
|
||||
assert line in output
|
||||
assert line.strip() in output
|
||||
|
||||
|
||||
def test_osxphotos_help_1():
|
||||
@@ -239,7 +315,7 @@ def test_osxphotos_help_1():
|
||||
output = result.output
|
||||
assert result.exit_code == 0
|
||||
for line in CLI_OUTPUT_NO_SUBCOMMAND:
|
||||
assert line in output
|
||||
assert line.strip() in output
|
||||
|
||||
|
||||
def test_osxphotos_help_2():
|
||||
@@ -249,7 +325,6 @@ def test_osxphotos_help_2():
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["help", "persons"])
|
||||
output = result.output
|
||||
assert result.exit_code == 0
|
||||
assert "Print out persons (faces) found in the Photos library." in result.output
|
||||
|
||||
@@ -261,7 +336,6 @@ def test_osxphotos_help_3():
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["help", "foo"])
|
||||
output = result.output
|
||||
assert result.exit_code == 0
|
||||
assert "Invalid command: foo" in result.output
|
||||
|
||||
@@ -298,7 +372,10 @@ def test_query_uuid():
|
||||
for key_ in json_expected:
|
||||
assert key_ in json_got
|
||||
if key_ != "path":
|
||||
assert json_expected[key_] == json_got[key_]
|
||||
if isinstance(json_expected[key_], list):
|
||||
assert sorted(json_expected[key_]) == sorted(json_got[key_])
|
||||
else:
|
||||
assert json_expected[key_] == json_got[key_]
|
||||
else:
|
||||
assert json_expected[key_] in json_got[key_]
|
||||
|
||||
@@ -516,14 +593,484 @@ def test_query_date():
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
import logging
|
||||
|
||||
logging.warning(result.output)
|
||||
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 4
|
||||
|
||||
|
||||
def test_query_keyword_1():
|
||||
"""Test query --keyword """
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--keyword", "Kids"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 4
|
||||
|
||||
|
||||
def test_query_keyword_2():
|
||||
"""Test query --keyword with lower case keyword"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--keyword", "kids"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 0
|
||||
|
||||
|
||||
def test_query_keyword_3():
|
||||
"""Test query --keyword with lower case keyword and --ignore-case"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--keyword",
|
||||
"kids",
|
||||
"--ignore-case",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 4
|
||||
|
||||
|
||||
def test_query_keyword_4():
|
||||
"""Test query with more than one --keyword"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--keyword",
|
||||
"Kids",
|
||||
"--keyword",
|
||||
"wedding",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 6
|
||||
|
||||
|
||||
def test_query_person_1():
|
||||
"""Test query --person"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--person", "Katie"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 3
|
||||
|
||||
|
||||
def test_query_person_2():
|
||||
"""Test query --person with lower case person"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--person", "katie"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 0
|
||||
|
||||
|
||||
def test_query_person_3():
|
||||
"""Test query --person with lower case person and --ignore-case"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--person",
|
||||
"katie",
|
||||
"--ignore-case",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 3
|
||||
|
||||
|
||||
def test_query_person_4():
|
||||
"""Test query with multiple --person"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--person",
|
||||
"Katie",
|
||||
"--person",
|
||||
"Maria",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 4
|
||||
|
||||
|
||||
def test_query_album_1():
|
||||
"""Test query --album"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--album",
|
||||
"Pumpkin Farm",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 3
|
||||
|
||||
|
||||
def test_query_album_2():
|
||||
"""Test query --album with lower case album"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--album",
|
||||
"pumpkin farm",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 0
|
||||
|
||||
|
||||
def test_query_album_3():
|
||||
"""Test query --album with lower case album and --ignore-case"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--album",
|
||||
"pumpkin farm",
|
||||
"--ignore-case",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 3
|
||||
|
||||
|
||||
def test_query_album_4():
|
||||
"""Test query with multipl --album"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--album",
|
||||
"Pumpkin Farm",
|
||||
"--album",
|
||||
"Raw",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 7
|
||||
|
||||
|
||||
def test_query_label_1():
|
||||
"""Test query --label"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--label", "Statue"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 1
|
||||
|
||||
|
||||
def test_query_label_2():
|
||||
"""Test query --label with lower case label """
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--label", "statue"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 0
|
||||
|
||||
|
||||
def test_query_label_3():
|
||||
"""Test query --label with lower case label and --ignore-case"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--label",
|
||||
"statue",
|
||||
"--ignore-case",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 1
|
||||
|
||||
|
||||
def test_query_label_4():
|
||||
"""Test query with more than one --label"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--label",
|
||||
"Statue",
|
||||
"--label",
|
||||
"Plant",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == 6
|
||||
|
||||
|
||||
def test_query_deleted_deleted_only():
|
||||
"""Test query with --deleted and --deleted-only"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
"--deleted",
|
||||
"--deleted-only",
|
||||
],
|
||||
)
|
||||
assert "Incompatible query options" in result.output
|
||||
|
||||
|
||||
def test_query_deleted_1():
|
||||
"""Test query with --deleted"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--deleted"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == PHOTOS_NOT_IN_TRASH_LEN_15_5 + PHOTOS_IN_TRASH_LEN_15_5
|
||||
|
||||
|
||||
def test_query_deleted_2():
|
||||
"""Test query with --deleted"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_14_6), "--deleted"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == PHOTOS_NOT_IN_TRASH_LEN_14_6 + PHOTOS_IN_TRASH_LEN_14_6
|
||||
|
||||
|
||||
def test_query_deleted_3():
|
||||
"""Test query with --deleted-only"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--deleted-only"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == PHOTOS_IN_TRASH_LEN_15_5
|
||||
assert json_got[0]["intrash"]
|
||||
|
||||
|
||||
def test_query_deleted_4():
|
||||
"""Test query with --deleted-only"""
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_14_6), "--deleted-only"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
assert len(json_got) == PHOTOS_IN_TRASH_LEN_14_6
|
||||
assert json_got[0]["intrash"]
|
||||
|
||||
|
||||
def test_export_sidecar():
|
||||
import glob
|
||||
import os
|
||||
@@ -554,6 +1101,48 @@ def test_export_sidecar():
|
||||
assert sorted(files) == sorted(CLI_EXPORT_SIDECAR_FILENAMES)
|
||||
|
||||
|
||||
def test_export_sidecar_templates():
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
|
||||
from osxphotos.__main__ import cli
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"export",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||
".",
|
||||
"--sidecar=json",
|
||||
f"--uuid={CLI_UUID_DICT_15_5['template']}",
|
||||
"-V",
|
||||
"--keyword-template",
|
||||
"{person}",
|
||||
"--description-template",
|
||||
"{descr} {person} {keyword} {album}",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert os.path.isfile(CLI_TEMPLATE_SIDECAR_FILENAME)
|
||||
with open(CLI_TEMPLATE_SIDECAR_FILENAME, "r") as jsonfile:
|
||||
exifdata = json.load(jsonfile)
|
||||
assert (
|
||||
exifdata[0]["XMP:Description"][0]
|
||||
== "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Test Album"
|
||||
)
|
||||
assert (
|
||||
exifdata[0]["EXIF:ImageDescription"][0]
|
||||
== "Girls with pumpkins Katie, Suzy Kids Pumpkin Farm, Test Album"
|
||||
)
|
||||
|
||||
|
||||
def test_export_live():
|
||||
import glob
|
||||
import os
|
||||
@@ -1001,6 +1590,138 @@ def test_export_album_deleted_twin():
|
||||
assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_DELETED_TWIN)
|
||||
|
||||
|
||||
def test_export_deleted_1():
|
||||
"""Test export with --deleted """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"]
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, PHOTOS_DB_15_5), ".", "--deleted", *skip]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert (
|
||||
len(files)
|
||||
== PHOTOS_NOT_IN_TRASH_LEN_15_5
|
||||
+ PHOTOS_IN_TRASH_LEN_15_5
|
||||
- PHOTOS_MISSING_15_5
|
||||
)
|
||||
|
||||
|
||||
def test_export_deleted_2():
|
||||
"""Test export with --deleted """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"]
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, PHOTOS_DB_14_6), ".", "--deleted", *skip]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert (
|
||||
len(files)
|
||||
== PHOTOS_NOT_IN_TRASH_LEN_14_6
|
||||
+ PHOTOS_IN_TRASH_LEN_14_6
|
||||
- PHOTOS_MISSING_14_6
|
||||
)
|
||||
|
||||
|
||||
def test_export_not_deleted_1():
|
||||
"""Test export does not find intrash files without --deleted flag """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"]
|
||||
result = runner.invoke(export, [os.path.join(cwd, PHOTOS_DB_15_5), ".", *skip])
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert len(files) == PHOTOS_NOT_IN_TRASH_LEN_15_5 - PHOTOS_MISSING_15_5
|
||||
|
||||
|
||||
def test_export_not_deleted_2():
|
||||
"""Test export does not find intrash files without --deleted flag """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"]
|
||||
result = runner.invoke(export, [os.path.join(cwd, PHOTOS_DB_14_6), ".", *skip])
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert len(files) == PHOTOS_NOT_IN_TRASH_LEN_14_6 - PHOTOS_MISSING_14_6
|
||||
|
||||
|
||||
def test_export_deleted_only_1():
|
||||
"""Test export with --deleted-only """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"]
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, PHOTOS_DB_15_5), ".", "--deleted-only", *skip]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert len(files) == PHOTOS_IN_TRASH_LEN_15_5
|
||||
|
||||
|
||||
def test_export_deleted_only_2():
|
||||
"""Test export with --deleted-only """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"]
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, PHOTOS_DB_14_6), ".", "--deleted-only", *skip]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
assert len(files) == PHOTOS_IN_TRASH_LEN_14_6
|
||||
|
||||
|
||||
def test_places():
|
||||
import json
|
||||
import os
|
||||
@@ -1260,8 +1981,6 @@ def test_export_sidecar_keyword_template():
|
||||
"EXIF:ModifyDate": "2020:04:11 12:34:16"}]"""
|
||||
)[0]
|
||||
|
||||
import logging
|
||||
|
||||
json_file = open("Pumkins2.json", "r")
|
||||
json_got = json.load(json_file)[0]
|
||||
json_file.close()
|
||||
@@ -1311,6 +2030,59 @@ def test_export_update_basic():
|
||||
)
|
||||
|
||||
|
||||
def test_export_update_child_folder():
|
||||
""" test export then update into a child folder of previous export """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export, OSXPHOTOS_EXPORT_DB
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
# basic export
|
||||
result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "-V"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
os.mkdir("foo")
|
||||
|
||||
# update into foo
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, CLI_PHOTOS_DB), "foo", "--update"], input="N\n"
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "WARNING: found other export database files" in result.output
|
||||
|
||||
|
||||
def test_export_update_parent_folder():
|
||||
""" test export then update into a parent folder of previous export """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import export, OSXPHOTOS_EXPORT_DB
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
# basic export
|
||||
os.mkdir("foo")
|
||||
result = runner.invoke(export, [os.path.join(cwd, CLI_PHOTOS_DB), "foo", "-V"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# update into "."
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, CLI_PHOTOS_DB), ".", "--update"], input="N\n"
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "WARNING: found other export database files" in result.output
|
||||
|
||||
|
||||
@pytest.mark.skipif(exiftool is None, reason="exiftool not installed")
|
||||
def test_export_update_exiftool():
|
||||
""" test export then update with exiftool """
|
||||
@@ -1626,3 +2398,79 @@ def test_export_directory_template_1_dry_run():
|
||||
for filepath in CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES1:
|
||||
assert f"Exported {filepath}" in result.output
|
||||
assert not os.path.isfile(os.path.join(workdir, filepath))
|
||||
|
||||
|
||||
def test_labels():
|
||||
"""Test osxphotos labels """
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import labels
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
labels, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
json_got = json.loads(result.output)
|
||||
assert json_got == LABELS_JSON
|
||||
|
||||
|
||||
def test_keywords():
|
||||
"""Test osxphotos keywords """
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import keywords
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
keywords, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
json_got = json.loads(result.output)
|
||||
assert json_got == KEYWORDS_JSON
|
||||
|
||||
|
||||
def test_albums():
|
||||
"""Test osxphotos albums """
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import albums
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
albums, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
json_got = json.loads(result.output)
|
||||
assert json_got == ALBUMS_JSON
|
||||
|
||||
|
||||
def test_persons():
|
||||
"""Test osxphotos albums """
|
||||
import json
|
||||
import osxphotos
|
||||
import os
|
||||
import os.path
|
||||
from osxphotos.__main__ import persons
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
persons, ["--db", os.path.join(cwd, PHOTOS_DB_15_5), "--json"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
json_got = json.loads(result.output)
|
||||
assert json_got == PERSONS_JSON
|
||||
|
||||
@@ -455,7 +455,6 @@ def test_exiftool_json_sidecar():
|
||||
"XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
|
||||
"EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
|
||||
"Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
|
||||
"EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
|
||||
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
@@ -586,7 +585,7 @@ def test_xmp_sidecar():
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
@@ -595,7 +594,7 @@ def test_xmp_sidecar():
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
@@ -603,10 +602,13 @@ def test_xmp_sidecar():
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"""
|
||||
@@ -647,7 +649,7 @@ def test_xmp_sidecar_use_persons_keyword():
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
@@ -656,7 +658,7 @@ def test_xmp_sidecar_use_persons_keyword():
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
@@ -666,11 +668,14 @@ def test_xmp_sidecar_use_persons_keyword():
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"""
|
||||
|
||||
@@ -710,7 +715,7 @@ def test_xmp_sidecar_use_albums_keyword():
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
@@ -719,7 +724,7 @@ def test_xmp_sidecar_use_albums_keyword():
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
@@ -729,11 +734,14 @@ def test_xmp_sidecar_use_albums_keyword():
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"""
|
||||
|
||||
@@ -746,3 +754,75 @@ def test_xmp_sidecar_use_albums_keyword():
|
||||
sorted(xmp_expected_lines), sorted(xmp_got_lines)
|
||||
):
|
||||
assert line_expected == line_got
|
||||
|
||||
|
||||
def test_xmp_sidecar_gps():
|
||||
""" Test export XMP sidecar with GPS info """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["location"]])
|
||||
|
||||
xmp_expected = """<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
|
||||
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||
<dc:description></dc:description>
|
||||
<dc:title>St. James's Park</dc:title>
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
<rdf:li>UK</rdf:li>
|
||||
<rdf:li>England</rdf:li>
|
||||
<rdf:li>London</rdf:li>
|
||||
<rdf:li>United Kingdom</rdf:li>
|
||||
<rdf:li>London 2018</rdf:li>
|
||||
<rdf:li>St. James's Park</rdf:li>
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-10-13T09:18:12.501000-04:00</photoshop:DateCreated>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
<rdf:li>UK</rdf:li>
|
||||
<rdf:li>England</rdf:li>
|
||||
<rdf:li>London</rdf:li>
|
||||
<rdf:li>United Kingdom</rdf:li>
|
||||
<rdf:li>London 2018</rdf:li>
|
||||
<rdf:li>St. James's Park</rdf:li>
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
<xmp:CreateDate>2018-10-13T09:18:12</xmp:CreateDate>
|
||||
<xmp:ModifyDate>2018-10-13T09:18:12</xmp:ModifyDate>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
<exif:GPSLongitudeRef>W</exif:GPSLongitudeRef>
|
||||
<exif:GPSLongitude>0.1318055</exif:GPSLongitude>
|
||||
<exif:GPSLatitude>51.50357167</exif:GPSLatitude>
|
||||
<exif:GPSLatitudeRef>N</exif:GPSLatitudeRef>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"""
|
||||
|
||||
xmp_expected_lines = [line.strip() for line in xmp_expected.split("\n")]
|
||||
|
||||
xmp_got = photos[0]._xmp_sidecar()
|
||||
xmp_got_lines = [line.strip() for line in xmp_got.split("\n")]
|
||||
|
||||
for line_expected, line_got in zip(
|
||||
sorted(xmp_expected_lines), sorted(xmp_got_lines)
|
||||
):
|
||||
assert line_expected == line_got
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ def test_xmp_sidecar_keyword_template():
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
@@ -187,7 +187,7 @@ def test_xmp_sidecar_keyword_template():
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
@@ -198,11 +198,14 @@ def test_xmp_sidecar_keyword_template():
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"""
|
||||
|
||||
|
||||
@@ -380,7 +380,6 @@ def test_exiftool_json_sidecar():
|
||||
"XMP:Subject": ["London 2018", "St. James\'s Park", "England", "United Kingdom", "UK", "London"],
|
||||
"EXIF:GPSLatitude": "51 deg 30\' 12.86\\" N",
|
||||
"EXIF:GPSLongitude": "0 deg 7\' 54.50\\" W",
|
||||
"Composite:GPSPosition": "51 deg 30\' 12.86\\" N, 0 deg 7\' 54.50\\" W",
|
||||
"EXIF:GPSLatitudeRef": "North", "EXIF:GPSLongitudeRef": "West",
|
||||
"EXIF:DateTimeOriginal": "2018:10:13 09:18:12",
|
||||
"EXIF:OffsetTimeOriginal": "-04:00",
|
||||
@@ -431,7 +430,7 @@ def test_xmp_sidecar():
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
@@ -440,7 +439,7 @@ def test_xmp_sidecar():
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
@@ -448,11 +447,14 @@ def test_xmp_sidecar():
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"""
|
||||
|
||||
@@ -490,7 +492,7 @@ def test_xmp_sidecar_keyword_template():
|
||||
</dc:subject>
|
||||
<photoshop:DateCreated>2018-09-28T15:35:49.063000-04:00</photoshop:DateCreated>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
@@ -499,7 +501,7 @@ def test_xmp_sidecar_keyword_template():
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
@@ -510,11 +512,14 @@ def test_xmp_sidecar_keyword_template():
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
<xmp:CreateDate>2018-09-28T15:35:49</xmp:CreateDate>
|
||||
<xmp:ModifyDate>2018-09-28T15:35:49</xmp:ModifyDate>
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>"""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
# TODO: put some of this code into a pre-function
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.13.6.photoslibrary/database/photos.db"
|
||||
KEYWORDS = [
|
||||
@@ -14,7 +14,7 @@ KEYWORDS = [
|
||||
"UK",
|
||||
"United Kingdom",
|
||||
]
|
||||
PERSONS = ["Katie", "Suzy", "Maria"]
|
||||
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
|
||||
ALBUMS = ["Pumpkin Farm", "AlbumInFolder", "TestAlbum"]
|
||||
KEYWORDS_DICT = {
|
||||
"Kids": 4,
|
||||
@@ -27,7 +27,7 @@ KEYWORDS_DICT = {
|
||||
"UK": 1,
|
||||
"United Kingdom": 1,
|
||||
}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1, _UNKNOWN_PERSON: 1}
|
||||
ALBUM_DICT = {"Pumpkin Farm": 3, "TestAlbum": 1, "AlbumInFolder": 1}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
# TODO: put some of this code into a pre-function
|
||||
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.14.5.photoslibrary/database/photos.db"
|
||||
KEYWORDS = [
|
||||
@@ -14,7 +15,7 @@ KEYWORDS = [
|
||||
"UK",
|
||||
"United Kingdom",
|
||||
]
|
||||
PERSONS = ["Katie", "Suzy", "Maria"]
|
||||
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
|
||||
ALBUMS = ["Pumpkin Farm"]
|
||||
KEYWORDS_DICT = {
|
||||
"Kids": 4,
|
||||
@@ -27,7 +28,7 @@ KEYWORDS_DICT = {
|
||||
"UK": 1,
|
||||
"United Kingdom": 1,
|
||||
}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1, _UNKNOWN_PERSON: 1}
|
||||
ALBUM_DICT = {"Pumpkin Farm": 3}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
# TODO: put some of this code into a pre-function
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
|
||||
PHOTOS_DB = "./tests/Test-10.14.6.photoslibrary/database/photos.db"
|
||||
PHOTOS_DB_PATH = "/Test-10.14.6.photoslibrary/database/photos.db"
|
||||
@@ -17,7 +17,7 @@ KEYWORDS = [
|
||||
"UK",
|
||||
"United Kingdom",
|
||||
]
|
||||
PERSONS = ["Katie", "Suzy", "Maria"]
|
||||
PERSONS = ["Katie", "Suzy", "Maria", _UNKNOWN_PERSON]
|
||||
ALBUMS = ["Pumpkin Farm", "AlbumInFolder", "Test Album", "Test Album (1)"]
|
||||
KEYWORDS_DICT = {
|
||||
"Kids": 4,
|
||||
@@ -30,7 +30,7 @@ KEYWORDS_DICT = {
|
||||
"UK": 1,
|
||||
"United Kingdom": 1,
|
||||
}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1}
|
||||
PERSONS_DICT = {"Katie": 3, "Suzy": 2, "Maria": 1, _UNKNOWN_PERSON: 1}
|
||||
ALBUM_DICT = {
|
||||
"Pumpkin Farm": 3,
|
||||
"AlbumInFolder": 1,
|
||||
@@ -41,8 +41,23 @@ ALBUM_DICT = {
|
||||
UUID_DICT = {
|
||||
"favorite": "6bxcNnzRQKGnK4uPrCJ9UQ",
|
||||
"not_favorite": "8SOE9s0XQVGsuq4ONohTng",
|
||||
"date_invalid": "YZFCPY24TUySvpu7owiqxA",
|
||||
"intrash": "3tljdX43R8+k6peNHVrJNQ",
|
||||
"not_intrash": "6bxcNnzRQKGnK4uPrCJ9UQ",
|
||||
"has_adjustments": "6bxcNnzRQKGnK4uPrCJ9UQ",
|
||||
}
|
||||
|
||||
ALBUM_SORT_ORDER = [
|
||||
"HrK3ZQdlQ7qpDA0FgOYXLA",
|
||||
"8SOE9s0XQVGsuq4ONohTng",
|
||||
"15uNd7%8RguTEgNPKHfTWw",
|
||||
]
|
||||
ALBUM_KEY_PHOTO = "15uNd7%8RguTEgNPKHfTWw"
|
||||
|
||||
PHOTOS_DB_LEN = 8
|
||||
PHOTOS_NOT_IN_TRASH_LEN = 7
|
||||
PHOTOS_IN_TRASH_LEN = 1
|
||||
|
||||
|
||||
def test_init():
|
||||
import osxphotos
|
||||
@@ -58,12 +73,13 @@ def test_db_version():
|
||||
assert photosdb.db_version in osxphotos._constants._TESTED_DB_VERSIONS
|
||||
assert photosdb.db_version == "4025"
|
||||
|
||||
|
||||
def test_db_len():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
# assert photosdb.db_version in osxphotos._TESTED_DB_VERSIONS
|
||||
assert len(photosdb) == 7
|
||||
assert len(photosdb) == PHOTOS_DB_LEN
|
||||
|
||||
|
||||
def test_os_version():
|
||||
@@ -127,6 +143,17 @@ def test_albums_as_dict():
|
||||
assert albums == ALBUM_DICT
|
||||
|
||||
|
||||
def test_album_sort_order():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
album = [a for a in photosdb.album_info if a.title == "Pumpkin Farm"][0]
|
||||
photos = album.photos
|
||||
|
||||
uuids = [p.uuid for p in photos]
|
||||
assert uuids == ALBUM_SORT_ORDER
|
||||
|
||||
|
||||
def test_attributes():
|
||||
import datetime
|
||||
import osxphotos
|
||||
@@ -153,6 +180,44 @@ def test_attributes():
|
||||
assert p.ismissing == False
|
||||
|
||||
|
||||
def test_attributes_2():
|
||||
""" Test attributes including height, width, etc """
|
||||
import datetime
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["has_adjustments"]])
|
||||
assert len(photos) == 1
|
||||
p = photos[0]
|
||||
assert p.keywords == ["wedding"]
|
||||
assert p.original_filename == "wedding.jpg"
|
||||
assert p.filename == "wedding.jpg"
|
||||
assert p.date == datetime.datetime(
|
||||
2019,
|
||||
4,
|
||||
15,
|
||||
14,
|
||||
40,
|
||||
24,
|
||||
86000,
|
||||
datetime.timezone(datetime.timedelta(seconds=-14400)),
|
||||
)
|
||||
assert p.description == "Bride Wedding day"
|
||||
assert p.title is None
|
||||
assert sorted(p.albums) == []
|
||||
assert p.persons == ["Maria"]
|
||||
assert p.path.endswith("Masters/2019/07/27/20190727-131650/wedding.jpg")
|
||||
assert not p.ismissing
|
||||
assert p.hasadjustments
|
||||
assert p.height == 1325
|
||||
assert p.width == 1526
|
||||
assert p.original_height == 1367
|
||||
assert p.original_width == 2048
|
||||
assert p.orientation == 1
|
||||
assert p.original_orientation == 1
|
||||
assert p.original_filesize == 460483
|
||||
|
||||
|
||||
def test_missing():
|
||||
import osxphotos
|
||||
|
||||
@@ -160,8 +225,8 @@ def test_missing():
|
||||
photos = photosdb.photos(uuid=["od0fmC7NQx+ayVr+%i06XA"])
|
||||
assert len(photos) == 1
|
||||
p = photos[0]
|
||||
assert p.path == None
|
||||
assert p.ismissing == True
|
||||
assert p.path is None
|
||||
assert p.ismissing
|
||||
|
||||
|
||||
def test_favorite():
|
||||
@@ -307,7 +372,63 @@ def test_count():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos()
|
||||
assert len(photos) == 7
|
||||
assert len(photos) == PHOTOS_NOT_IN_TRASH_LEN
|
||||
|
||||
|
||||
def test_photos_intrash_1():
|
||||
""" test PhotosDB.photos(intrash=True) """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(intrash=True)
|
||||
assert len(photos) == PHOTOS_IN_TRASH_LEN
|
||||
|
||||
|
||||
def test_photos_intrash_2():
|
||||
""" test PhotosDB.photos(intrash=True) """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(intrash=True)
|
||||
for p in photos:
|
||||
assert p.intrash
|
||||
|
||||
|
||||
def test_photos_intrash_2():
|
||||
""" test PhotosDB.photos(intrash=False) """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(intrash=False)
|
||||
for p in photos:
|
||||
assert not p.intrash
|
||||
|
||||
|
||||
def test_photoinfo_intrash_1():
|
||||
""" Test PhotoInfo.intrash """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
p = photosdb.photos(uuid=[UUID_DICT["intrash"]], intrash=True)[0]
|
||||
assert p.intrash
|
||||
|
||||
|
||||
def test_photoinfo_intrash_2():
|
||||
""" Test PhotoInfo.intrash and intrash=default"""
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
p = photosdb.photos(uuid=[UUID_DICT["intrash"]])
|
||||
assert not p
|
||||
|
||||
|
||||
def test_photoinfo_not_intrash():
|
||||
""" Test PhotoInfo.intrash """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
p = photosdb.photos(uuid=[UUID_DICT["not_intrash"]])[0]
|
||||
assert not p.intrash
|
||||
|
||||
|
||||
def test_keyword_2():
|
||||
@@ -407,3 +528,30 @@ def test_multi_person():
|
||||
photos = photosdb.photos(persons=["Katie", "Suzy"])
|
||||
|
||||
assert len(photos) == 3
|
||||
|
||||
|
||||
def test_date_invalid():
|
||||
""" Test date is invalid """
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["date_invalid"]])
|
||||
assert len(photos) == 1
|
||||
p = photos[0]
|
||||
delta = timedelta(seconds=p.tzoffset)
|
||||
tz = timezone(delta)
|
||||
assert p.date == datetime(1970, 1, 1).astimezone(tz=tz)
|
||||
|
||||
|
||||
def test_date_modified_invalid():
|
||||
""" Test date modified is invalid """
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["date_invalid"]])
|
||||
assert len(photos) == 1
|
||||
p = photos[0]
|
||||
assert p.date_modified is None
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ ALBUM_DICT = {}
|
||||
|
||||
UUID_DICT = {"movie": "CfnR005YQ1uvNdq8UcnFtw", "image": "XuKdBnARTB+fPyyY+uh4fQ"}
|
||||
|
||||
PHOTOS_LEN = 6
|
||||
MOVIES_LEN = 1
|
||||
|
||||
|
||||
def test_init():
|
||||
import osxphotos
|
||||
@@ -97,8 +100,8 @@ def test_count_photos():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos()
|
||||
assert len(photos) == 6
|
||||
photos = photosdb.photos(movies=False)
|
||||
assert len(photos) == PHOTOS_LEN
|
||||
|
||||
|
||||
def test_count_movies():
|
||||
@@ -106,7 +109,7 @@ def test_count_movies():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(movies=True, images=False)
|
||||
assert len(photos) == 1
|
||||
assert len(photos) == MOVIES_LEN
|
||||
|
||||
|
||||
def test_count_movies_2():
|
||||
@@ -114,7 +117,7 @@ def test_count_movies_2():
|
||||
|
||||
# if don't ask for movies=True, won't get any
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["movie"]])
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["movie"]], movies=False)
|
||||
assert len(photos) == 0
|
||||
|
||||
|
||||
@@ -123,7 +126,7 @@ def test_count_all():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(images=True, movies=True)
|
||||
assert len(photos) == 7
|
||||
assert len(photos) == PHOTOS_LEN + MOVIES_LEN
|
||||
|
||||
|
||||
def test_uti_movie():
|
||||
|
||||
@@ -16,6 +16,9 @@ UUID_DICT = {
|
||||
"image": "FF158787-3EA0-4B06-8D93-4E7E362495DE",
|
||||
}
|
||||
|
||||
PHOTOS_LEN = 6
|
||||
MOVIES_LEN = 1
|
||||
|
||||
|
||||
def test_init():
|
||||
import osxphotos
|
||||
@@ -102,8 +105,8 @@ def test_count_photos():
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos()
|
||||
assert len(photos) == 6
|
||||
photos = photosdb.photos(movies=False)
|
||||
assert len(photos) == PHOTOS_LEN
|
||||
|
||||
|
||||
def test_count_movies():
|
||||
@@ -111,7 +114,7 @@ def test_count_movies():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(movies=True, images=False)
|
||||
assert len(photos) == 1
|
||||
assert len(photos) == MOVIES_LEN
|
||||
|
||||
|
||||
def test_count_movies_2():
|
||||
@@ -119,7 +122,7 @@ def test_count_movies_2():
|
||||
|
||||
# if don't ask for movies=True, won't get any
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["movie"]])
|
||||
photos = photosdb.photos(uuid=[UUID_DICT["movie"]], movies=False)
|
||||
assert len(photos) == 0
|
||||
|
||||
|
||||
@@ -128,7 +131,7 @@ def test_count_all():
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB)
|
||||
photos = photosdb.photos(images=True, movies=True)
|
||||
assert len(photos) == 7
|
||||
assert len(photos) == PHOTOS_LEN + MOVIES_LEN
|
||||
|
||||
|
||||
def test_uti_movie():
|
||||
|
||||
97
tests/test_score_info.py
Normal file
@@ -0,0 +1,97 @@
|
||||
""" Test ScoreInfo """
|
||||
|
||||
from math import isclose
|
||||
import pytest
|
||||
|
||||
from osxphotos.photoinfo import ScoreInfo
|
||||
|
||||
PHOTOS_DB_5 = "tests/Test-10.15.5.photoslibrary"
|
||||
PHOTOS_DB_4 = "tests/Test-10.14.6.photoslibrary"
|
||||
|
||||
SCORE_DICT = {
|
||||
"4D521201-92AC-43E5-8F7C-59BC41C37A96": ScoreInfo(
|
||||
overall=0.470703125,
|
||||
curation=0.5,
|
||||
promotion=0.0,
|
||||
highlight_visibility=0.03816793893129771,
|
||||
behavioral=0.0,
|
||||
failure=-0.0006928443908691406,
|
||||
harmonious_color=0.017852783203125,
|
||||
immersiveness=0.003086090087890625,
|
||||
interaction=0.019999999552965164,
|
||||
interesting_subject=-0.0885009765625,
|
||||
intrusive_object_presence=-0.037872314453125,
|
||||
lively_color=0.10540771484375,
|
||||
low_light=0.00824737548828125,
|
||||
noise=-0.015655517578125,
|
||||
pleasant_camera_tilt=-0.006256103515625,
|
||||
pleasant_composition=0.028564453125,
|
||||
pleasant_lighting=-0.00439453125,
|
||||
pleasant_pattern=0.09088134765625,
|
||||
pleasant_perspective=0.11859130859375,
|
||||
pleasant_post_processing=0.00698089599609375,
|
||||
pleasant_reflection=-0.01523590087890625,
|
||||
pleasant_symmetry=0.01242828369140625,
|
||||
sharply_focused_subject=0.08538818359375,
|
||||
tastefully_blurred=0.022125244140625,
|
||||
well_chosen_subject=0.05596923828125,
|
||||
well_framed_subject=0.5986328125,
|
||||
well_timed_shot=0.0134124755859375,
|
||||
),
|
||||
"6191423D-8DB8-4D4C-92BE-9BBBA308AAC4": ScoreInfo(
|
||||
overall=0.853515625,
|
||||
curation=0.75,
|
||||
promotion=0.0,
|
||||
highlight_visibility=0.05725190839694656,
|
||||
behavioral=0.0,
|
||||
failure=-0.0004916191101074219,
|
||||
harmonious_color=0.382080078125,
|
||||
immersiveness=0.0133209228515625,
|
||||
interaction=0.03999999910593033,
|
||||
interesting_subject=0.1632080078125,
|
||||
intrusive_object_presence=-0.00966644287109375,
|
||||
lively_color=0.44091796875,
|
||||
low_light=0.01322174072265625,
|
||||
noise=-0.0026721954345703125,
|
||||
pleasant_camera_tilt=0.028045654296875,
|
||||
pleasant_composition=0.33642578125,
|
||||
pleasant_lighting=0.46142578125,
|
||||
pleasant_pattern=0.1944580078125,
|
||||
pleasant_perspective=0.494384765625,
|
||||
pleasant_post_processing=0.4970703125,
|
||||
pleasant_reflection=0.00910186767578125,
|
||||
pleasant_symmetry=0.00930023193359375,
|
||||
sharply_focused_subject=0.52490234375,
|
||||
tastefully_blurred=0.63916015625,
|
||||
well_chosen_subject=0.64208984375,
|
||||
well_framed_subject=0.485595703125,
|
||||
well_timed_shot=0.01531219482421875,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def photosdb():
|
||||
import osxphotos
|
||||
|
||||
return osxphotos.PhotosDB(dbfile=PHOTOS_DB_5)
|
||||
|
||||
|
||||
def test_score_info_v5(photosdb):
|
||||
""" test score """
|
||||
# use math.isclose to compare floats
|
||||
# on MacOS x64 these can probably compared for equality but would possibly
|
||||
# fail if osxphotos ever ported to other platforms
|
||||
for uuid in SCORE_DICT:
|
||||
photo = photosdb.photos(uuid=[uuid], movies=True)[0]
|
||||
for attr in photo.score.__dict__:
|
||||
assert isclose(getattr(photo.score, attr), getattr(SCORE_DICT[uuid], attr))
|
||||
|
||||
|
||||
def test_score_info_v4():
|
||||
""" test version 4, score should be None """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_4)
|
||||
for photo in photosdb.photos():
|
||||
assert photo.score is None
|
||||
@@ -32,6 +32,9 @@ TEMPLATE_VALUES = {
|
||||
"{created.dd}": "04",
|
||||
"{created.dow}": "Tuesday",
|
||||
"{created.doy}": "035",
|
||||
"{created.hour}": "19",
|
||||
"{created.min}": "07",
|
||||
"{created.sec}": "38",
|
||||
"{modified.date}": "2020-03-21",
|
||||
"{modified.year}": "2020",
|
||||
"{modified.yy}": "20",
|
||||
@@ -40,6 +43,9 @@ TEMPLATE_VALUES = {
|
||||
"{modified.mon}": "Mar",
|
||||
"{modified.dd}": "21",
|
||||
"{modified.doy}": "081",
|
||||
"{modified.hour}": "01",
|
||||
"{modified.min}": "33",
|
||||
"{modified.sec}": "08",
|
||||
"{place.name}": "Washington, District of Columbia, United States",
|
||||
"{place.country_code}": "US",
|
||||
"{place.name.country}": "United States",
|
||||
@@ -106,7 +112,7 @@ def test_lookup():
|
||||
|
||||
for subst in TEMPLATE_SUBSTITUTIONS:
|
||||
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
|
||||
lookup = template.get_template_value(lookup_str)
|
||||
lookup = template.get_template_value(lookup_str, None)
|
||||
assert lookup or lookup is None
|
||||
|
||||
|
||||
@@ -115,7 +121,10 @@ def test_lookup_multi():
|
||||
import os
|
||||
import re
|
||||
import osxphotos
|
||||
from osxphotos.phototemplate import TEMPLATE_SUBSTITUTIONS_MULTI_VALUED, PhotoTemplate
|
||||
from osxphotos.phototemplate import (
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
PhotoTemplate,
|
||||
)
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||
@@ -123,10 +132,11 @@ def test_lookup_multi():
|
||||
|
||||
for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED:
|
||||
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
|
||||
lookup = template.get_template_value_multi(lookup_str,path_sep=os.path.sep)
|
||||
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep)
|
||||
assert isinstance(lookup, list)
|
||||
assert len(lookup) >= 1
|
||||
|
||||
|
||||
def test_subst():
|
||||
""" Test that substitutions are correct """
|
||||
import locale
|
||||
@@ -432,3 +442,64 @@ def test_subst_multi_folder_albums_3():
|
||||
rendered, unknown = photo.render_template(template)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_subst_strftime():
|
||||
""" Test that strftime substitutions are correct """
|
||||
import locale
|
||||
import osxphotos
|
||||
|
||||
locale.setlocale(locale.LC_ALL, "en_US")
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||
|
||||
rendered, unmatched = photo.render_template("{created.strftime,%Y-%m-%d-%H%M%S}")
|
||||
assert rendered[0] == "2020-02-04-190738"
|
||||
|
||||
rendered, unmatched = photo.render_template("{created.strftime}")
|
||||
assert rendered[0] == "_"
|
||||
|
||||
|
||||
def test_subst_expand_inplace_1():
|
||||
""" Test that substitutions are correct when expand_inplace=True """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
||||
|
||||
template = "{person}"
|
||||
expected = ["Katie,Suzy"]
|
||||
rendered, unknown = photo.render_template(template, expand_inplace=True)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
|
||||
|
||||
def test_subst_expand_inplace_2():
|
||||
""" Test that substitutions are correct when expand_inplace=True """
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
||||
|
||||
template = "{person}-{keyword}"
|
||||
expected = ["Katie,Suzy-Kids"]
|
||||
rendered, unknown = photo.render_template(template, expand_inplace=True)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
|
||||
|
||||
def test_subst_expand_inplace_3():
|
||||
""" Test that substitutions are correct when expand_inplace=True and inplace_sep specified"""
|
||||
import osxphotos
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1)
|
||||
# one album, one keyword, two persons
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0]
|
||||
|
||||
template = "{person}-{keyword}"
|
||||
expected = ["Katie; Suzy-Kids"]
|
||||
rendered, unknown = photo.render_template(
|
||||
template, expand_inplace=True, inplace_sep="; "
|
||||
)
|
||||
assert sorted(rendered) == sorted(expected)
|
||||
|
||||
|
||||
70
tests/test_template_today.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
PHOTOS_DB_PLACES = (
|
||||
"./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db"
|
||||
)
|
||||
|
||||
DATETIME_TODAY = datetime.datetime(2020, 6, 21, 13, 0, 0)
|
||||
""" Used to patch osxphotos.phototemplate.TODAY for testing """
|
||||
|
||||
UUID_DICT = {
|
||||
"place_dc": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546",
|
||||
"1_1_2": "1EB2B765-0765-43BA-A90C-0D0580E6172C",
|
||||
"2_1_1": "D79B8D77-BFFC-460B-9312-034F2877D35B",
|
||||
"0_2_0": "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4",
|
||||
"folder_album_1": "3DD2C897-F19E-4CA6-8C22-B027D5A71907",
|
||||
"folder_album_no_folder": "D79B8D77-BFFC-460B-9312-034F2877D35B",
|
||||
"mojave_album_1": "15uNd7%8RguTEgNPKHfTWw",
|
||||
}
|
||||
|
||||
TODAY_VALUES = {
|
||||
"{today.date}": "2020-06-21",
|
||||
"{today.year}": "2020",
|
||||
"{today.yy}": "20",
|
||||
"{today.mm}": "06",
|
||||
"{today.month}": "June",
|
||||
"{today.mon}": "Jun",
|
||||
"{today.dd}": "21",
|
||||
"{today.dow}": "Sunday",
|
||||
"{today.doy}": "173",
|
||||
"{today.hour}": "13",
|
||||
"{today.min}": "00",
|
||||
"{today.sec}": "00",
|
||||
}
|
||||
|
||||
|
||||
def test_subst_today():
|
||||
""" Test that substitutions are correct for {today.x}"""
|
||||
import locale
|
||||
import osxphotos
|
||||
|
||||
locale.setlocale(locale.LC_ALL, "en_US")
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||
|
||||
photo_template = osxphotos.PhotoTemplate(photo)
|
||||
photo_template.today = DATETIME_TODAY
|
||||
|
||||
for template in TODAY_VALUES:
|
||||
rendered, _ = photo_template.render(template)
|
||||
assert rendered[0] == TODAY_VALUES[template]
|
||||
|
||||
|
||||
def test_subst_strftime_today():
|
||||
""" Test that strftime substitutions are correct for {today.strftime}"""
|
||||
import locale
|
||||
import osxphotos
|
||||
|
||||
locale.setlocale(locale.LC_ALL, "en_US")
|
||||
photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES)
|
||||
photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0]
|
||||
|
||||
photo_template = osxphotos.PhotoTemplate(photo)
|
||||
photo_template.today = DATETIME_TODAY
|
||||
rendered, unmatched = photo_template.render("{today.strftime,%Y-%m-%d-%H%M%S}")
|
||||
assert rendered[0] == "2020-06-21-130000"
|
||||
|
||||
rendered, unmatched = photo.render_template("{today.strftime}")
|
||||
assert rendered[0] == "_"
|
||||
@@ -1,6 +1,6 @@
|
||||
""" Builds the template table in markdown format for README.md """
|
||||
|
||||
from osxphotos.photoinfo.template import (
|
||||
from osxphotos.phototemplate import (
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
)
|
||||
|
||||