Compare commits
277 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
459d91d7b1 | ||
|
|
eb00ffd737 | ||
|
|
a1776fa148 | ||
|
|
f1d20103ff | ||
|
|
5f2d401048 | ||
|
|
58b3869a7c | ||
|
|
c2fecc9d30 | ||
|
|
1f343c1c11 | ||
|
|
a36eb416b1 | ||
|
|
c9b15186a0 | ||
|
|
315fe6a6a3 | ||
|
|
b611d34d19 | ||
|
|
001e474d56 | ||
|
|
60d96a8f56 | ||
|
|
42e8fba125 | ||
|
|
a91617cce4 | ||
|
|
0cc4beaede | ||
|
|
0f457a4082 | ||
|
|
1f717b0579 | ||
|
|
0cbd005bcd | ||
|
|
1bf7105737 | ||
|
|
6e5ea8e013 | ||
|
|
9f64262757 | ||
|
|
6c11e3fa5b | ||
|
|
c9c9202205 | ||
|
|
ebd878a075 | ||
|
|
2cf3b6bb67 | ||
|
|
beb7970b3b | ||
|
|
2567974f5b | ||
|
|
78d494ff2c | ||
|
|
eefa1f181f | ||
|
|
2bf5fae093 | ||
|
|
9b13d1e00b | ||
|
|
f2df6f1a12 | ||
|
|
98e417023e | ||
|
|
360c8d8e1b | ||
|
|
868cda8482 | ||
|
|
fa149dc7e1 | ||
|
|
7467bbf62b | ||
|
|
d2deefff83 | ||
|
|
f474dcd2cb | ||
|
|
6acf9acd63 | ||
|
|
d0ec8620c7 | ||
|
|
10156e34b5 | ||
|
|
a714ae0af0 | ||
|
|
fc416ea0b7 | ||
|
|
2628c1f2d2 | ||
|
|
e482c3915a | ||
|
|
6baeae7ddd | ||
|
|
bea770b322 | ||
|
|
840e9937be | ||
|
|
002fce8e93 | ||
|
|
ef32b1e9bc | ||
|
|
6f29cda99f | ||
|
|
9fc4f76219 | ||
|
|
65b84ad345 | ||
|
|
cf4dca10c0 | ||
|
|
27040d1604 | ||
|
|
b91a9828fa | ||
|
|
8c10b61e90 | ||
|
|
b7f4b739de | ||
|
|
f8e62d8f5e | ||
|
|
da551036f9 | ||
|
|
d52b387a29 | ||
|
|
927e25911e | ||
|
|
6688d1ff64 | ||
|
|
3526881ec8 | ||
|
|
3f19276c5c | ||
|
|
091e7b8f2e | ||
|
|
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 | ||
|
|
06fa1edcae | ||
|
|
cf2615da62 | ||
|
|
4ba1982d74 | ||
|
|
abd10b73e8 | ||
|
|
7cd7b51598 | ||
|
|
801dc62c4b | ||
|
|
72f034ef85 | ||
|
|
cb993f2e5e | ||
|
|
2271d89355 | ||
|
|
62d096b5a1 | ||
|
|
5c7a0c3a24 | ||
|
|
ec727cc556 | ||
|
|
6c84827ec7 | ||
|
|
d47fd46a21 | ||
|
|
7707bc1625 | ||
|
|
fc1bf08cfa | ||
|
|
f35ea70b72 | ||
|
|
16f802bf71 | ||
|
|
8d2d5a8a33 | ||
|
|
3a8bef1572 | ||
|
|
fd71b13c77 | ||
|
|
2243395bff | ||
|
|
42b89d34f3 | ||
|
|
9b11cbf32b | ||
|
|
8c3bf040c6 | ||
|
|
c0855c7702 | ||
|
|
288b1abc1c | ||
|
|
9eae66030e | ||
|
|
46fdc94398 | ||
|
|
09c7d18901 | ||
|
|
5f071b9c3f | ||
|
|
8df6d2c707 | ||
|
|
28935b0af9 | ||
|
|
af750dd2e3 | ||
|
|
1d095d7284 | ||
|
|
f67f239278 | ||
|
|
441de711dc | ||
|
|
1450b3ccac | ||
|
|
c06c230a46 | ||
|
|
b1171e96cc | ||
|
|
8c4fe40aa6 | ||
|
|
f416418546 | ||
|
|
8e9691d6d7 | ||
|
|
cafa483cfc | ||
|
|
11d368a69c | ||
|
|
bd9d5a26f3 | ||
|
|
ec19636cbf | ||
|
|
48e9c32add | ||
|
|
b455bd4c4a | ||
|
|
d125854f2a | ||
|
|
e228cfab74 | ||
|
|
85760dc4fe | ||
|
|
be07f90e5a | ||
|
|
a80dee401c | ||
|
|
e67fce2871 | ||
|
|
53304d7023 | ||
|
|
d1af14dbb4 | ||
|
|
ca8f2b8d5c | ||
|
|
e3e5a20681 | ||
|
|
98b3f63a92 | ||
|
|
c8128203f4 | ||
|
|
c061161605 | ||
|
|
397db0d72f | ||
|
|
605d63aa4f | ||
|
|
ac9b6d52a3 | ||
|
|
57315d4449 | ||
|
|
49cfd0b93e | ||
|
|
b15f744aab | ||
|
|
cc8b10e792 | ||
|
|
93835130c2 | ||
|
|
df13683d11 | ||
|
|
b0ec6c6b36 | ||
|
|
cb124713d6 | ||
|
|
97d3c69ade | ||
|
|
a8622b6b90 | ||
|
|
cceab62993 | ||
|
|
69356c0b57 | ||
|
|
5eb0876e33 | ||
|
|
7444b6d173 | ||
|
|
180450c1e7 | ||
|
|
00e16611fc | ||
|
|
65674f57bc | ||
|
|
7af1ccd4ed | ||
|
|
1b6f661e6b | ||
|
|
a57da2346b | ||
|
|
3fe03cd127 | ||
|
|
5cc98c338b | ||
|
|
1c9d4f282b | ||
|
|
1ceda15134 | ||
|
|
a80071111f | ||
|
|
072a8d795e | ||
|
|
b35b071634 | ||
|
|
56a000609f | ||
|
|
54d5d4b7ba | ||
|
|
38137a1351 | ||
|
|
4b29a2e05f | ||
|
|
9be0f849b7 | ||
|
|
ccb5f252d1 | ||
|
|
d8a64c9573 | ||
|
|
81d4e392c3 | ||
|
|
85d2baac10 | ||
|
|
8a768e62ce | ||
|
|
1c8eb764f5 | ||
|
|
8e4b88ad1f | ||
|
|
3f80f786a3 | ||
|
|
a337e79e13 | ||
|
|
ec68feec49 | ||
|
|
9b9b54e590 | ||
|
|
22f1e8f2a6 | ||
|
|
1867c1d747 | ||
|
|
87eb84fddd | ||
|
|
15a3736b74 | ||
|
|
cf28cb6452 | ||
|
|
f20fadcef7 | ||
|
|
3bac106eb7 | ||
|
|
47d1c82c03 | ||
|
|
6f281711e2 | ||
|
|
4b30b3b426 | ||
|
|
1fa9583ea6 | ||
|
|
235e1fb1a6 | ||
|
|
36c2821a0f | ||
|
|
ed425724a0 | ||
|
|
55daa31c71 | ||
|
|
b6ac9e1ea3 | ||
|
|
9d151478d6 | ||
|
|
7d55844390 | ||
|
|
f398e9116f | ||
|
|
4fe8190b57 | ||
|
|
7e42ebb240 | ||
|
|
edae116baa | ||
|
|
d542cda17d | ||
|
|
99b5b54c6d | ||
|
|
379feddcda | ||
|
|
24285f5dd2 | ||
|
|
3cb3ebb300 | ||
|
|
16037f10fa | ||
|
|
ebd21491ac | ||
|
|
b7c7b9f066 | ||
|
|
21e7020fec | ||
|
|
952741d488 | ||
|
|
9fef12ed37 | ||
|
|
97362fc0f1 | ||
|
|
e09f0b40f1 | ||
|
|
b749681c6d | ||
|
|
8544667c72 | ||
|
|
d6a22b765a | ||
|
|
96365728c2 | ||
|
|
c01f713f00 | ||
|
|
1aa3838c38 | ||
|
|
cde56e9d13 | ||
|
|
1c9da5ed6f | ||
|
|
d74f7f499b | ||
|
|
c85bb02304 |
11
.github/workflows/pythonpackage.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Python package
|
||||
name: Tests
|
||||
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.6, 3.7, 3.8]
|
||||
python-version: [3.7, 3.8]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
@@ -21,8 +21,8 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# - name: Lint with flake8
|
||||
# run: |
|
||||
# pip install flake8
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
@@ -31,4 +31,5 @@ jobs:
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pip install pytest
|
||||
pip install pytest-mock
|
||||
python -m pytest tests/
|
||||
|
||||
513
CHANGELOG.md
@@ -4,6 +4,490 @@ 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.34.0](https://github.com/RhetTbull/osxphotos/compare/v0.33.8...v0.34.0)
|
||||
|
||||
> 7 September 2020
|
||||
|
||||
- Added --skip-original-if-edited for issue #159 [`5f2d401`](https://github.com/RhetTbull/osxphotos/commit/5f2d401048850fd68f31b37a7e71abc11ca80dc5)
|
||||
- Still working on issue #208 [`58b3869`](https://github.com/RhetTbull/osxphotos/commit/58b3869a7cce7cb3f211599e544d7e5426ceb4a6)
|
||||
|
||||
#### [v0.33.8](https://github.com/RhetTbull/osxphotos/compare/v0.33.7...v0.33.8)
|
||||
|
||||
> 31 August 2020
|
||||
|
||||
- Fixed sidecar collisions, closes #210 [`#210`](https://github.com/RhetTbull/osxphotos/issues/210)
|
||||
|
||||
#### [v0.33.7](https://github.com/RhetTbull/osxphotos/compare/v0.33.5...v0.33.7)
|
||||
|
||||
> 31 August 2020
|
||||
|
||||
- typo fix - thanks to @dmd [`#212`](https://github.com/RhetTbull/osxphotos/pull/212)
|
||||
- Normalize unicode for issue #208 [`a36eb41`](https://github.com/RhetTbull/osxphotos/commit/a36eb416b19284477922b6a5f837f4040327138b)
|
||||
- Added force_download.py to examples [`b611d34`](https://github.com/RhetTbull/osxphotos/commit/b611d34d19db480af72f57ef55eacd0a32c8d1e8)
|
||||
- Added photoshop:SidecarForExtension to XMP, partial fix for #210 [`60d96a8`](https://github.com/RhetTbull/osxphotos/commit/60d96a8f563882fba2365a6ab58c1276725eedaa)
|
||||
- Updated README.md [`c9b1518`](https://github.com/RhetTbull/osxphotos/commit/c9b15186a022d91248451279e5f973e3f2dca4b4)
|
||||
- Update README.md [`42e8fba`](https://github.com/RhetTbull/osxphotos/commit/42e8fba125a3c6b1bd0d538f2af511aabfbeb478)
|
||||
|
||||
#### [v0.33.5](https://github.com/RhetTbull/osxphotos/compare/v0.33.3...v0.33.5)
|
||||
|
||||
> 25 August 2020
|
||||
|
||||
- Fixed DST handling for from_date/to_date, closes #193 (again) [`#193`](https://github.com/RhetTbull/osxphotos/issues/193)
|
||||
- Added raw timestamps to PhotoInfo._info [`0f457a4`](https://github.com/RhetTbull/osxphotos/commit/0f457a4082a4eebc42a5df2160a02ad987b6f96c)
|
||||
|
||||
#### [v0.33.3](https://github.com/RhetTbull/osxphotos/compare/v0.33.2...v0.33.3)
|
||||
|
||||
> 23 August 2020
|
||||
|
||||
- Fixed portrait for Catalina/Big Sur; see issue #203 [`1f717b0`](https://github.com/RhetTbull/osxphotos/commit/1f717b05794c2088c7c15d2aab0c5d24b6309c06)
|
||||
|
||||
#### [v0.33.2](https://github.com/RhetTbull/osxphotos/compare/v0.33.0...v0.33.2)
|
||||
|
||||
> 23 August 2020
|
||||
|
||||
- Closes issue #206, adds --touch-file [`#207`](https://github.com/RhetTbull/osxphotos/pull/207)
|
||||
- Touch files - fixes #194 -- thanks to @PabloKohan [`#205`](https://github.com/RhetTbull/osxphotos/pull/205)
|
||||
- Refactor/cleanup _export_photo - thanks to @PabloKohan [`#204`](https://github.com/RhetTbull/osxphotos/pull/204)
|
||||
- Finished --touch-file, closes #206 [`#206`](https://github.com/RhetTbull/osxphotos/issues/206)
|
||||
- Merge pull request #205 from PabloKohan/touch_files__fix_194 [`#194`](https://github.com/RhetTbull/osxphotos/issues/194)
|
||||
- --touch-file now working with --update [`6c11e3f`](https://github.com/RhetTbull/osxphotos/commit/6c11e3fa5b5b05b98b9fdbb0e59e3a78c7dff980)
|
||||
- Refactor/cleanup _export_photo [`eefa1f1`](https://github.com/RhetTbull/osxphotos/commit/eefa1f181f4fd7b027ae69abd2b764afb590c081)
|
||||
- Fixed touch tests [`1bf7105`](https://github.com/RhetTbull/osxphotos/commit/1bf7105737fbd756064a2f9ef4d4bbd0b067978c)
|
||||
- Working on issue 206 [`ebd878a`](https://github.com/RhetTbull/osxphotos/commit/ebd878a075983ef3df0b1ead1a725e01508721f8)
|
||||
- Working on issue #206 [`c9c9202`](https://github.com/RhetTbull/osxphotos/commit/c9c920220545dc27c8cb1379d7bde15987cce72c)
|
||||
|
||||
#### [v0.33.0](https://github.com/RhetTbull/osxphotos/compare/v0.32.0...v0.33.0)
|
||||
|
||||
> 17 August 2020
|
||||
|
||||
- Replaced call to which, closes #171 [`#171`](https://github.com/RhetTbull/osxphotos/issues/171)
|
||||
- Added contributors to README.md, closes #200 [`#200`](https://github.com/RhetTbull/osxphotos/issues/200)
|
||||
- Added tests for 10.15.6 [`d2deeff`](https://github.com/RhetTbull/osxphotos/commit/d2deefff834e46e1a26adc01b1b025ac839dbc78)
|
||||
- Added ImportInfo for Photos 5+ [`98e4170`](https://github.com/RhetTbull/osxphotos/commit/98e417023ec5bd8292b25040d0844f3706645950)
|
||||
- Update README.md [`360c8d8`](https://github.com/RhetTbull/osxphotos/commit/360c8d8e1b4760e95a8b71b3a0bf0df4fb5adaf5)
|
||||
- Update README.md [`868cda8`](https://github.com/RhetTbull/osxphotos/commit/868cda8482ce6b29dd00e04a209d40550e6b128b)
|
||||
|
||||
#### [v0.32.0](https://github.com/RhetTbull/osxphotos/compare/v0.31.2...v0.32.0)
|
||||
|
||||
> 9 August 2020
|
||||
|
||||
- Alpha support for MacOS Big Sur/10.16, see issue #187 [`6acf9ac`](https://github.com/RhetTbull/osxphotos/commit/6acf9acd6364e1996158179493d128ec0958e652)
|
||||
|
||||
#### [v0.31.2](https://github.com/RhetTbull/osxphotos/compare/v0.31.0...v0.31.2)
|
||||
|
||||
> 9 August 2020
|
||||
|
||||
- Fixed from_date and to_date to be timezone aware, closes #193 [`#193`](https://github.com/RhetTbull/osxphotos/issues/193)
|
||||
- Added test for valid XMP file, closes #197 [`#197`](https://github.com/RhetTbull/osxphotos/issues/197)
|
||||
- Dropped py36 due to datetime.fromisoformat [`a714ae0`](https://github.com/RhetTbull/osxphotos/commit/a714ae0af089b13acf70c4f29934393aa48ed222)
|
||||
- Added --uuid-from-file to CLI [`840e993`](https://github.com/RhetTbull/osxphotos/commit/840e9937bede407ef55972a361618683245e086b)
|
||||
- Added write_uuid_to_file.applescript to utils [`bea770b`](https://github.com/RhetTbull/osxphotos/commit/bea770b322d21cf3f8245d20e182006247cb71d6)
|
||||
- Updated README.md [`002fce8`](https://github.com/RhetTbull/osxphotos/commit/002fce8e93edd936d4b866118ae6d4c94e5d6744)
|
||||
- Added py37 [`d0ec862`](https://github.com/RhetTbull/osxphotos/commit/d0ec8620c721fe7576ab7d519a5eaac4d17a317e)
|
||||
|
||||
#### [v0.31.0](https://github.com/RhetTbull/osxphotos/compare/v0.30.13...v0.31.0)
|
||||
|
||||
> 27 July 2020
|
||||
|
||||
- Initial FaceInfo support for Issue #21 [`6f29cda`](https://github.com/RhetTbull/osxphotos/commit/6f29cda99f1b8d94a95597c7046620cf21fecae4)
|
||||
- Updated Github Actions to run on PR [`9fc4f76`](https://github.com/RhetTbull/osxphotos/commit/9fc4f762193699dd45b586b51aa2d3066928aab1)
|
||||
|
||||
#### [v0.30.13](https://github.com/RhetTbull/osxphotos/compare/v0.30.12...v0.30.13)
|
||||
|
||||
> 23 July 2020
|
||||
|
||||
- This reverts commit b7f4b739de978991def8ae2dca0f4e4b2881f56d, reversing [`#191`](https://github.com/RhetTbull/osxphotos/pull/191)
|
||||
- Fix findfiles not to fail on missing/invalid dir [`#192`](https://github.com/RhetTbull/osxphotos/pull/192)
|
||||
- Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" [`#191`](https://github.com/RhetTbull/osxphotos/pull/191)
|
||||
- Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133) [`#190`](https://github.com/RhetTbull/osxphotos/pull/190)
|
||||
- Fix findfiles not to fail on missing/invalid dir [`8c10b61`](https://github.com/RhetTbull/osxphotos/commit/8c10b61e90abbcfdff472bad4bb760558c7b850c)
|
||||
- Revert "Fix FileExistsError when filename differs only in case and export-as-hardlink (Bug#133)" [`f8e62d8`](https://github.com/RhetTbull/osxphotos/commit/f8e62d8f5ed26814f02383426237fd4c99a7ad04)
|
||||
- Fix FileExistsError when filename differs only in case and export-as-hardlink [`d52b387`](https://github.com/RhetTbull/osxphotos/commit/d52b387a294e68ebf0580a202ea70b97205560ef)
|
||||
- Version bump for bug fix [`cf4dca1`](https://github.com/RhetTbull/osxphotos/commit/cf4dca10c02d5f3f6132ab1572a698379b667e48)
|
||||
|
||||
#### [v0.30.12](https://github.com/RhetTbull/osxphotos/compare/v0.30.10...v0.30.12)
|
||||
|
||||
> 18 July 2020
|
||||
|
||||
- Implemented PersonInfo, closes #181 [`#181`](https://github.com/RhetTbull/osxphotos/issues/181)
|
||||
- Updated dependencies, now supports py36, py37, py38 [`6688d1f`](https://github.com/RhetTbull/osxphotos/commit/6688d1ff6491f2e7e155946b265ef8b5d8929441)
|
||||
- Update README.md [`3526881`](https://github.com/RhetTbull/osxphotos/commit/3526881ec872cc009b0d8936f366afcfff166d42)
|
||||
|
||||
#### [v0.30.10](https://github.com/RhetTbull/osxphotos/compare/v0.30.9...v0.30.10)
|
||||
|
||||
> 6 July 2020
|
||||
|
||||
- Bug fix for empty albums [`1ef518c`](https://github.com/RhetTbull/osxphotos/commit/1ef518cc3e9efbe9d4c16aa3d36c6dc6db86798e)
|
||||
|
||||
#### [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)
|
||||
|
||||
#### [v0.29.9](https://github.com/RhetTbull/osxphotos/compare/v0.29.8...v0.29.9)
|
||||
|
||||
> 31 May 2020
|
||||
|
||||
- Added --filename to CLI, closes #89 [`#89`](https://github.com/RhetTbull/osxphotos/issues/89)
|
||||
|
||||
#### [v0.29.8](https://github.com/RhetTbull/osxphotos/compare/v0.29.5...v0.29.8)
|
||||
|
||||
> 31 May 2020
|
||||
|
||||
- Added --edited-suffix to CLI, closes #145 [`#145`](https://github.com/RhetTbull/osxphotos/issues/145)
|
||||
- refactored render_template, closes #149 [`#149`](https://github.com/RhetTbull/osxphotos/issues/149)
|
||||
- Added test for Photos 5 on 10.15.5 [`2243395`](https://github.com/RhetTbull/osxphotos/commit/2243395bff9e1cc379626cc5007e44e6e63b95e0)
|
||||
- Refactored template code out of PhotoInfo into PhotoTemplate [`16f802b`](https://github.com/RhetTbull/osxphotos/commit/16f802bf717610e13712b8aa477d05d94b14d294)
|
||||
- Added test for SearchInfo on 10.15.5 [`3a8bef1`](https://github.com/RhetTbull/osxphotos/commit/3a8bef1572e4d83b1e0a4b85c8f06e329cc7e8de)
|
||||
- performance improvements for update and export_db [`42b89d3`](https://github.com/RhetTbull/osxphotos/commit/42b89d34f3d14818daefbd3bfabc1be9344d2e1a)
|
||||
- More refactoring in PhotoTemplate [`f35ea70`](https://github.com/RhetTbull/osxphotos/commit/f35ea70b72e8c6743b1f6009466d2a15d40338ac)
|
||||
|
||||
#### [v0.29.5](https://github.com/RhetTbull/osxphotos/compare/v0.29.2...v0.29.5)
|
||||
|
||||
> 25 May 2020
|
||||
|
||||
- added created.dow (day of week) to template [`#147`](https://github.com/RhetTbull/osxphotos/pull/147)
|
||||
- Added --dry-run option to CLI export, closes #91 [`#91`](https://github.com/RhetTbull/osxphotos/issues/91)
|
||||
- added created.dd and modified.dd to template system, closes #135 [`#135`](https://github.com/RhetTbull/osxphotos/issues/135)
|
||||
- Catch exception in folder processing to address #148 [`46fdc94`](https://github.com/RhetTbull/osxphotos/commit/46fdc94398c80b157048649434c7312074ce5c58)
|
||||
- added created.dow (day of week) to template [`8df6d2c`](https://github.com/RhetTbull/osxphotos/commit/8df6d2c707caf4eb35696888282365a128b69569)
|
||||
- Added test for DateTimeFormatter.dow [`09c7d18`](https://github.com/RhetTbull/osxphotos/commit/09c7d18901b61669d8b9242babd82eba6987c89a)
|
||||
|
||||
#### [v0.29.2](https://github.com/RhetTbull/osxphotos/compare/v0.29.1...v0.29.2)
|
||||
|
||||
> 24 May 2020
|
||||
|
||||
- Added try/except for bad datettime values [`1d095d7`](https://github.com/RhetTbull/osxphotos/commit/1d095d7284bae57037b8b200c8b3422835c611b2)
|
||||
|
||||
#### [v0.29.1](https://github.com/RhetTbull/osxphotos/compare/v0.29.0...v0.29.1)
|
||||
|
||||
> 23 May 2020
|
||||
|
||||
- Catch illegal timestamp value [`#146`](https://github.com/RhetTbull/osxphotos/pull/146)
|
||||
- Catch illegal timestamp value [`441de71`](https://github.com/RhetTbull/osxphotos/commit/441de711dc664b244d599c81e3dd1bcd9b2e55a0)
|
||||
|
||||
#### [v0.29.0](https://github.com/RhetTbull/osxphotos/compare/v0.28.19...v0.29.0)
|
||||
|
||||
> 23 May 2020
|
||||
|
||||
- Made --exiftool and --export-as-hardlink incompatible in CLI to fix #132 [`#132`](https://github.com/RhetTbull/osxphotos/issues/132)
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
> 15 May 2020
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
#### [v0.28.17](https://github.com/RhetTbull/osxphotos/compare/v0.28.15...v0.28.17)
|
||||
|
||||
> 14 May 2020
|
||||
|
||||
- Added ExifInfo (Photos 5 only) [`53304d7`](https://github.com/RhetTbull/osxphotos/commit/53304d702317d007056c1d12064503c3ec4ae6f6)
|
||||
- Added as_dict to ExifTool [`d1af14d`](https://github.com/RhetTbull/osxphotos/commit/d1af14dbb4d441a62d352123774e51fa3538db97)
|
||||
|
||||
#### [v0.28.15](https://github.com/RhetTbull/osxphotos/compare/v0.28.13...v0.28.15)
|
||||
|
||||
> 11 May 2020
|
||||
|
||||
- fixed some minor findings... [`#127`](https://github.com/RhetTbull/osxphotos/pull/127)
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
#### [v0.28.13](https://github.com/RhetTbull/osxphotos/compare/v0.28.10...v0.28.13)
|
||||
|
||||
> 2 May 2020
|
||||
|
||||
- added --keyword-template [`65674f5`](https://github.com/RhetTbull/osxphotos/commit/65674f57bc174c078e6c47f12ba3aaba87bfa3a4)
|
||||
- Fixed bug related to issue #119 [`7af1ccd`](https://github.com/RhetTbull/osxphotos/commit/7af1ccd4ed22ea7f0f86973bfba7f108b6650291)
|
||||
- test library updates [`1b6f661`](https://github.com/RhetTbull/osxphotos/commit/1b6f661e6b59c003d3b8cb35226ffb51469be508)
|
||||
|
||||
#### [v0.28.10](https://github.com/RhetTbull/osxphotos/compare/v0.28.8...v0.28.10)
|
||||
|
||||
> 29 April 2020
|
||||
|
||||
- Bug fix for albums in Photos <= 4 to address issue #116 [`a57da23`](https://github.com/RhetTbull/osxphotos/commit/a57da2346b282d731ed41db600bfc5cbeb1a0992)
|
||||
- version bump for pypi [`3fe03cd`](https://github.com/RhetTbull/osxphotos/commit/3fe03cd12752c2a7769007b6d934f1efe9f9c4d2)
|
||||
|
||||
#### [v0.28.8](https://github.com/RhetTbull/osxphotos/compare/v0.28.7...v0.28.8)
|
||||
|
||||
> 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)
|
||||
- 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)
|
||||
|
||||
#### [v0.28.7](https://github.com/RhetTbull/osxphotos/compare/v0.28.6...v0.28.7)
|
||||
|
||||
> 28 April 2020
|
||||
|
||||
- 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 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)
|
||||
|
||||
> 26 April 2020
|
||||
|
||||
- Fixed locale bug in templates, closes #113 [`#113`](https://github.com/RhetTbull/osxphotos/issues/113)
|
||||
- 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)
|
||||
|
||||
#### [v0.28.5](https://github.com/RhetTbull/osxphotos/compare/0.28.2...v0.28.5)
|
||||
|
||||
> 21 April 2020
|
||||
|
||||
- added __len__ to PhotosDB, closes #44 [`#44`](https://github.com/RhetTbull/osxphotos/issues/44)
|
||||
- Updated use of _PHOTOS_4_VERSION, closes #106 [`#106`](https://github.com/RhetTbull/osxphotos/issues/106)
|
||||
- Updated tests and test library with RAW images [`9b9b54e`](https://github.com/RhetTbull/osxphotos/commit/9b9b54e590e43ae49fb3ae41d493a1f8faec4181)
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
> 18 April 2020
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
#### [v0.28.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.4...v0.28.1)
|
||||
|
||||
> 18 April 2020
|
||||
|
||||
- Initial work on suppport for associated RAW images [`7e42ebb`](https://github.com/RhetTbull/osxphotos/commit/7e42ebb2402d45cd5d20bdd55bddddaa9db4679f)
|
||||
- Initial support for RAW photos in Photos 4 to address issue #101 [`9d15147`](https://github.com/RhetTbull/osxphotos/commit/9d151478d610291b8d482aafae3d445dfd391fca)
|
||||
- replaced CLI option --original-name with --current-name [`36c2821`](https://github.com/RhetTbull/osxphotos/commit/36c2821a0fa62eaaa54cf1edc2d9c6da98155354)
|
||||
|
||||
#### [v0.27.4](https://github.com/RhetTbull/osxphotos/compare/v0.27.3...v0.27.4)
|
||||
|
||||
> 12 April 2020
|
||||
|
||||
- Added {folder_album} to template and --folder to CLI [`b7c7b9f`](https://github.com/RhetTbull/osxphotos/commit/b7c7b9f0664e69c743bdd8a228ad2936cf6b7600)
|
||||
- Test library update [`21e7020`](https://github.com/RhetTbull/osxphotos/commit/21e7020fec406b0f3926d7adc8a1451bfe77e75a)
|
||||
|
||||
#### [v0.27.3](https://github.com/RhetTbull/osxphotos/compare/v0.27.1...v0.27.3)
|
||||
|
||||
> 12 April 2020
|
||||
|
||||
- Added additional tests for album_info [`97362fc`](https://github.com/RhetTbull/osxphotos/commit/97362fc0f13b2867abc013f4ba97ae60b0700894)
|
||||
- Fixed bug with handling of deleted albums [`9fef12e`](https://github.com/RhetTbull/osxphotos/commit/9fef12ed37634a7bdb11232976b4b2ddccd1a7cb)
|
||||
|
||||
#### [v0.27.1](https://github.com/RhetTbull/osxphotos/compare/v0.27.0...v0.27.1)
|
||||
|
||||
> 12 April 2020
|
||||
|
||||
- Changed AlbumInfo and FolderInfo interface to maintain backwards compatibility with PhotosDB.albums [`e09f0b4`](https://github.com/RhetTbull/osxphotos/commit/e09f0b40f1671d70ee399cdc519492b04fac8adc)
|
||||
|
||||
#### [v0.27.0](https://github.com/RhetTbull/osxphotos/compare/v0.26.1...v0.27.0)
|
||||
|
||||
> 11 April 2020
|
||||
|
||||
- Update README.md [`#95`](https://github.com/RhetTbull/osxphotos/pull/95)
|
||||
- Added tests and README for AlbumInfo and FolderInfo [`d6a22b7`](https://github.com/RhetTbull/osxphotos/commit/d6a22b765ab17f6ef1ba8c50b77946f090979968)
|
||||
- Added albuminfo.py for AlbumInfo and FolderInfo classes [`9636572`](https://github.com/RhetTbull/osxphotos/commit/96365728c2ff42abfb6828872ffac53b4c3c8024)
|
||||
- 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)
|
||||
|
||||
> 11 April 2020
|
||||
|
||||
- Bug fix for PhotosDB.photos() query [`1c9da5e`](https://github.com/RhetTbull/osxphotos/commit/1c9da5ed6ffa21f0577906b65b7da08951725d1f)
|
||||
- Updated test library [`d74f7f4`](https://github.com/RhetTbull/osxphotos/commit/d74f7f499bf59f37ec81cfa9d49cbbf3aafb5961)
|
||||
|
||||
#### [v0.26.0](https://github.com/RhetTbull/osxphotos/compare/v0.25.1...v0.26.0)
|
||||
|
||||
> 11 April 2020
|
||||
|
||||
- Added test for 10.15.4 [`1820715`](https://github.com/RhetTbull/osxphotos/commit/182071584904d001a9b199eef5febfb79e00696e)
|
||||
- Changed PhotosDB albums interface as prep for adding folders [`3e50626`](https://github.com/RhetTbull/osxphotos/commit/3e5062684ab6d706d91d4abeb4e3b0ca47867b70)
|
||||
- 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)
|
||||
|
||||
> 5 April 2020
|
||||
@@ -21,7 +505,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)
|
||||
|
||||
@@ -42,8 +526,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)
|
||||
|
||||
@@ -58,15 +542,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)
|
||||
@@ -82,7 +566,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)
|
||||
|
||||
@@ -97,18 +581,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)
|
||||
@@ -125,7 +608,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)
|
||||
|
||||
@@ -137,7 +620,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)
|
||||
|
||||
@@ -151,7 +634,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)
|
||||
|
||||
@@ -163,7 +646,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)
|
||||
|
||||
@@ -171,7 +654,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)
|
||||
|
||||
@@ -186,8 +669,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)
|
||||
|
||||
@@ -201,9 +684,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)
|
||||
|
||||
|
||||
19
cli.py
Normal file
@@ -0,0 +1,19 @@
|
||||
""" stand alone command line script for use with pyinstaller
|
||||
|
||||
To build this into an executable:
|
||||
- install pyinstaller:
|
||||
python3 -m pip install pyinstaller
|
||||
- then use make_cli_exe.sh to run pyinstaller or execute the following command:
|
||||
pyinstaller --onefile --hidden-import="pkg_resources.py2_warn" --name osxphotos --add-data osxphotos/templates/xmp_sidecar.mako:osxphotos/templates cli.py
|
||||
|
||||
Resulting executable will be in "dist/osxphotos"
|
||||
|
||||
Note: This is *not* the cli that "python3 -m pip install osxphotos" or "python setup.py install" would install;
|
||||
it's merely a wrapper around __main__.py to allow pyinstaller to work
|
||||
|
||||
"""
|
||||
|
||||
from osxphotos.__main__ import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -14,7 +14,7 @@ def main():
|
||||
|
||||
print(photosdb.keywords)
|
||||
print(photosdb.persons)
|
||||
print(photosdb.album_names)
|
||||
print(photosdb.albums)
|
||||
|
||||
print(photosdb.keywords_as_dict)
|
||||
print(photosdb.persons_as_dict)
|
||||
|
||||
@@ -79,7 +79,7 @@ def export(export_path, default_album, library_path, edited):
|
||||
exported = p.export(dest_dir, filename)
|
||||
click.echo(f"Exported {filename} to {exported}")
|
||||
else:
|
||||
click.echo(f"Skipping missing photo: {p.original_filename} in album {album}")
|
||||
click.echo(f"Skipping missing photo: {p.original_filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
83
examples/export_faces.py
Normal file
@@ -0,0 +1,83 @@
|
||||
""" Export all photos that contain a detected face and draw rectangles around each face
|
||||
photos with no persons/detected faces will not be export
|
||||
|
||||
This shows how to use the FaceInfo class and is useful for validating that FaceInfo is
|
||||
correctly handling faces.
|
||||
|
||||
To use this, you'll need to install Pillow:
|
||||
python3 -m pip install Pillow
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("export-path", type=click.Path(exists=True))
|
||||
@click.option(
|
||||
"--uuid",
|
||||
metavar="UUID",
|
||||
help="Limit export to optional UUID(s)",
|
||||
required=False,
|
||||
multiple=True,
|
||||
)
|
||||
@click.option(
|
||||
"--library-path",
|
||||
metavar="PATH",
|
||||
help="Path to Photos library, default to last used library",
|
||||
default=None,
|
||||
)
|
||||
def export(export_path, library_path, uuid):
|
||||
""" export photos to export_path and draw faces """
|
||||
library_path = os.path.expanduser(library_path) if library_path else None
|
||||
if library_path is not None:
|
||||
photosdb = osxphotos.PhotosDB(library_path)
|
||||
else:
|
||||
photosdb = osxphotos.PhotosDB()
|
||||
|
||||
photos = photosdb.photos(uuid=uuid) if uuid else photosdb.photos(movies=False)
|
||||
for p in photos:
|
||||
if p.person_info and not p.ismissing:
|
||||
# has persons and not missing
|
||||
if "heic" in p.filename.lower():
|
||||
print(f"skipping heic image {p.filename}")
|
||||
continue
|
||||
print(f"exporting photo {p.original_filename}, uuid = {p.uuid}")
|
||||
export = p.export(export_path, p.original_filename, edited=p.hasadjustments)
|
||||
if export:
|
||||
im = Image.open(export[0])
|
||||
draw = ImageDraw.Draw(im)
|
||||
for face in p.face_info:
|
||||
coords = face.face_rect()
|
||||
draw.rectangle(coords, width=3)
|
||||
draw.ellipse(get_circle_points(face.center, 3), width=1)
|
||||
draw.text(face.mouth, "M", fill=(255, 255, 255, 255))
|
||||
draw.text(face.left_eye, "L", fill=(255, 255, 255, 255))
|
||||
draw.text(face.right_eye, "R", fill=(255, 255, 255, 255))
|
||||
im.save(export[0])
|
||||
else:
|
||||
print(f"no photos exported for {p.uuid}")
|
||||
|
||||
|
||||
def get_circle_points(xy, radius):
|
||||
""" Returns tuples of (x0, y0), (x1, y1) for a circle centered at x, y with radius
|
||||
|
||||
Arguments:
|
||||
xy: tuple of x, y coordinates
|
||||
radius: radius of circle to draw
|
||||
|
||||
Returns:
|
||||
[(x0, y0), (x1, y1)] for bounding box of circle centered at x, y
|
||||
"""
|
||||
x, y = xy
|
||||
x0, y0 = x - radius, y - radius
|
||||
x1, y1 = x + radius, y + radius
|
||||
return [(x0, y0), (x1, y1)]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
export() # pylint: disable=no-value-for-parameter
|
||||
42
examples/force_download.py
Normal file
@@ -0,0 +1,42 @@
|
||||
""" use osxphotos to force the download of photos from iCloud
|
||||
downloads images to a temporary directory then deletes them
|
||||
resulting in the photo being downloaded to Photos library
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
def main():
|
||||
photosdb = osxphotos.PhotosDB()
|
||||
tempdir = tempfile.TemporaryDirectory()
|
||||
photos = photosdb.photos()
|
||||
downloaded = 0
|
||||
missing = [photo for photo in photos if photo.ismissing and not photo.shared]
|
||||
|
||||
if not missing:
|
||||
print(f"Did not find any missing photos to download")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Downloading {len(missing)} photos")
|
||||
for photo in missing:
|
||||
if photo.ismissing:
|
||||
print(f"Downloading photo {photo.original_filename}")
|
||||
downloaded += 1
|
||||
exported = photo.export(tempdir.name, use_photos_export=True, timeout=300)
|
||||
if photo.hasadjustments:
|
||||
exported.extend(
|
||||
photo.export(tempdir.name, use_photos_export=True, edited=True, timeout=300)
|
||||
)
|
||||
for filename in exported:
|
||||
print(f"Removing temporary file {filename}")
|
||||
os.unlink(filename)
|
||||
print(f"Downloaded {downloaded} photos")
|
||||
tempdir.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,6 +7,7 @@
|
||||
# If you run this using python from command line, do so with -i flag:
|
||||
# python3 -i examples/photos_repl.py
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -17,14 +18,27 @@ import osxphotos
|
||||
from osxphotos.__main__ import get_photos_db, _list_libraries
|
||||
|
||||
|
||||
def show(photo):
|
||||
""" open image with default image viewer
|
||||
|
||||
Note: This is for debugging only -- it will actually open any filetype which could
|
||||
be very, very bad.
|
||||
|
||||
Args:
|
||||
photo: PhotoInfo object or a path to a photo on disk
|
||||
"""
|
||||
photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
return f"'{photopath}' does not appear to be a valid photo path"
|
||||
|
||||
os.system(f"open '{photopath}'")
|
||||
|
||||
|
||||
def main():
|
||||
db = None
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
db = sys.argv[1]
|
||||
else:
|
||||
db = get_photos_db()
|
||||
|
||||
db = sys.argv[1] if len(sys.argv) > 1 else get_photos_db()
|
||||
if db:
|
||||
print("loading database")
|
||||
tic = time.perf_counter()
|
||||
@@ -44,5 +58,6 @@ if __name__ == "__main__":
|
||||
print("getting photos")
|
||||
tic = time.perf_counter()
|
||||
photos = photosdb.photos(images=True, movies=True)
|
||||
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
|
||||
toc = time.perf_counter()
|
||||
print(f"found {len(photos)} photos in {toc-tic} seconds")
|
||||
|
||||
8
make_cli_exe.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
|
||||
# This will build an stand-alone executable called 'osxphotos' in your ./dist directory
|
||||
# using pyinstaller
|
||||
# If you need to install pyinstaller:
|
||||
# python3 -m pip install --upgrade pyinstaller
|
||||
|
||||
pyinstaller --onefile --hidden-import="pkg_resources.py2_warn" --name osxphotos --add-data osxphotos/templates/xmp_sidecar.mako:osxphotos/templates cli.py
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -3,6 +3,14 @@ Constants used by osxphotos
|
||||
"""
|
||||
|
||||
import os.path
|
||||
from datetime import datetime
|
||||
|
||||
# Time delta: add this to Photos times to get unix time
|
||||
# Apple Epoch is Jan 1, 2001
|
||||
TIME_DELTA = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
|
||||
|
||||
# Unicode format to use for comparing strings
|
||||
UNICODE_FORMAT = "NFC"
|
||||
|
||||
# which Photos library database versions have been tested
|
||||
# Photos 2.0 (10.12.6) == 2622
|
||||
@@ -10,17 +18,48 @@ import os.path
|
||||
# Photos 4.0 (10.14.5) == 4016
|
||||
# Photos 4.0 (10.14.6) == 4025
|
||||
# Photos 5.0 (10.15.0) == 6000
|
||||
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
|
||||
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
|
||||
|
||||
# database model versions (applies to Photos 5, Photos 6)
|
||||
# these come from PLModelVersion key in binary plist in Z_METADATA.Z_PLIST
|
||||
# Photos 5 (10.15.1) == 13537
|
||||
# Photos 5 (10.15.4, 10.15.5, 10.15.6) == 13703
|
||||
# Photos 6 (10.16.0 Beta) == 14104
|
||||
_TEST_MODEL_VERSIONS = ["13537", "13703", "14104"]
|
||||
|
||||
# only version 3 - 4 have RKVersion.selfPortrait
|
||||
_PHOTOS_3_VERSION = "3301"
|
||||
|
||||
# versions later than this have a different database structure
|
||||
_PHOTOS_5_VERSION = "6000"
|
||||
# versions 5.0 and later have a different database structure
|
||||
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
|
||||
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.6
|
||||
|
||||
# Ranges for model version by Photos version
|
||||
_PHOTOS_5_MODEL_VERSION = [13000, 13999]
|
||||
_PHOTOS_6_MODEL_VERSION = [14000, 14999]
|
||||
|
||||
# some table names differ between Photos 5 and Photos 6
|
||||
_DB_TABLE_NAMES = {
|
||||
5: {
|
||||
"ASSET": "ZGENERICASSET",
|
||||
"KEYWORD_JOIN": "Z_1KEYWORDS.Z_37KEYWORDS",
|
||||
"ALBUM_JOIN": "Z_26ASSETS.Z_34ASSETS",
|
||||
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_34ASSETS",
|
||||
"IMPORT_FOK": "ZGENERICASSET.Z_FOK_IMPORTSESSION",
|
||||
"DEPTH_STATE": "ZGENERICASSET.ZDEPTHSTATES",
|
||||
},
|
||||
6: {
|
||||
"ASSET": "ZASSET",
|
||||
"KEYWORD_JOIN": "Z_1KEYWORDS.Z_36KEYWORDS",
|
||||
"ALBUM_JOIN": "Z_26ASSETS.Z_3ASSETS",
|
||||
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_3ASSETS",
|
||||
"IMPORT_FOK": "null",
|
||||
"DEPTH_STATE": "ZASSET.ZDEPTHTYPE",
|
||||
},
|
||||
}
|
||||
|
||||
# which major version operating systems have been tested
|
||||
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
|
||||
_TESTED_OS_VERSIONS = ["12", "13", "14", "15", "16"]
|
||||
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
_UNKNOWN_PERSON = "_UNKNOWN_"
|
||||
@@ -40,3 +79,26 @@ _MOVIE_TYPE = 1
|
||||
# Name of XMP template file
|
||||
_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
||||
_XMP_TEMPLATE_NAME = "xmp_sidecar.mako"
|
||||
|
||||
# Constants used for processing folders and albums
|
||||
_PHOTOS_5_ALBUM_KIND = 2 # normal user album
|
||||
_PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album
|
||||
_PHOTOS_5_FOLDER_KIND = 4000 # user folder
|
||||
_PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
|
||||
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND = 1506 # import session
|
||||
|
||||
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
|
||||
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
|
||||
|
||||
# EXIF related constants
|
||||
# max keyword length for IPTC:Keyword, reference
|
||||
# https://www.iptc.org/std/photometadata/documentation/userguide/
|
||||
_MAX_IPTC_KEYWORD_LEN = 64
|
||||
|
||||
# Sentinel value for detecting if a template in keyword_template doesn't match
|
||||
# If anyone has a keyword matching this, then too bad...
|
||||
_OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
|
||||
|
||||
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
|
||||
SEARCH_CATEGORY_LABEL = 2024
|
||||
|
||||
530
osxphotos/_export_db.py
Normal file
@@ -0,0 +1,530 @@
|
||||
""" Helper class for managing a database used by
|
||||
PhotoInfo.export for tracking state of exports and updates
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from io import StringIO
|
||||
from sqlite3 import Error
|
||||
|
||||
from ._version import __version__
|
||||
|
||||
OSXPHOTOS_EXPORTDB_VERSION = "1.0"
|
||||
|
||||
|
||||
class ExportDB_ABC(ABC):
|
||||
""" abstract base class for ExportDB """
|
||||
|
||||
@abstractmethod
|
||||
def get_uuid_for_file(self, filename):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_uuid_for_file(self, filename, uuid):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_stat_orig_for_file(self, filename, stats):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_stat_orig_for_file(self, filename):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_stat_exif_for_file(self, filename, stats):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_stat_exif_for_file(self, filename):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_info_for_uuid(self, uuid):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_info_for_uuid(self, uuid, info):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_exifdata_for_file(self, uuid):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_exifdata_for_file(self, uuid, exifdata):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
pass
|
||||
|
||||
|
||||
class ExportDBNoOp(ExportDB_ABC):
|
||||
""" An ExportDB with NoOp methods """
|
||||
|
||||
def get_uuid_for_file(self, filename):
|
||||
pass
|
||||
|
||||
def set_uuid_for_file(self, filename, uuid):
|
||||
pass
|
||||
|
||||
def set_stat_orig_for_file(self, filename, stats):
|
||||
pass
|
||||
|
||||
def get_stat_orig_for_file(self, filename):
|
||||
pass
|
||||
|
||||
def set_stat_exif_for_file(self, filename, stats):
|
||||
pass
|
||||
|
||||
def get_stat_exif_for_file(self, filename):
|
||||
pass
|
||||
|
||||
def get_info_for_uuid(self, uuid):
|
||||
pass
|
||||
|
||||
def set_info_for_uuid(self, uuid, info):
|
||||
pass
|
||||
|
||||
def get_exifdata_for_file(self, uuid):
|
||||
pass
|
||||
|
||||
def set_exifdata_for_file(self, uuid, exifdata):
|
||||
pass
|
||||
|
||||
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
pass
|
||||
|
||||
|
||||
class ExportDB(ExportDB_ABC):
|
||||
""" Interface to sqlite3 database used to store state information for osxphotos export command """
|
||||
|
||||
def __init__(self, dbfile):
|
||||
""" dbfile: path to osxphotos export database file """
|
||||
self._dbfile = dbfile
|
||||
# _path is parent of the database
|
||||
# all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this parent _path
|
||||
# this allows the entire export tree to be moved to a new disk/location
|
||||
# whilst preserving the UUID to filename mappping
|
||||
self._path = pathlib.Path(dbfile).parent
|
||||
self._conn = self._open_export_db(dbfile)
|
||||
self._insert_run_info()
|
||||
|
||||
def get_uuid_for_file(self, filename):
|
||||
""" query database for filename and return UUID
|
||||
returns None if filename not found in database
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
logging.debug(f"get_uuid: {filename}")
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"SELECT uuid FROM files WHERE filepath_normalized = ?", (filename,)
|
||||
)
|
||||
results = c.fetchone()
|
||||
uuid = results[0] if results else None
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
uuid = None
|
||||
|
||||
logging.debug(f"get_uuid: {uuid}")
|
||||
return uuid
|
||||
|
||||
def set_uuid_for_file(self, filename, uuid):
|
||||
""" set UUID of filename to uuid in the database """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path))
|
||||
filename_normalized = filename.lower()
|
||||
logging.debug(f"set_uuid: {filename} {uuid}")
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
|
||||
(filename, filename_normalized, uuid),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def set_stat_orig_for_file(self, filename, stats):
|
||||
""" set stat info for filename
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
if len(stats) != 3:
|
||||
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
|
||||
|
||||
logging.debug(f"set_stat_orig_for_file: {filename} {stats}")
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"UPDATE files "
|
||||
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
|
||||
+ "WHERE filepath_normalized = ?;",
|
||||
(*stats, filename),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def get_stat_orig_for_file(self, filename):
|
||||
""" get stat info for filename
|
||||
returns: tuple of (mode, size, mtime)
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"SELECT orig_mode, orig_size, orig_mtime FROM files WHERE filepath_normalized = ?",
|
||||
(filename,),
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
stats = (None, None, None)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
|
||||
logging.debug(f"get_stat_orig_for_file: {stats}")
|
||||
return stats
|
||||
|
||||
def set_stat_exif_for_file(self, filename, stats):
|
||||
""" set stat info for filename (after exiftool has updated it)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
if len(stats) != 3:
|
||||
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
|
||||
|
||||
logging.debug(f"set_stat_exif_for_file: {filename} {stats}")
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"UPDATE files "
|
||||
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
|
||||
+ "WHERE filepath_normalized = ?;",
|
||||
(*stats, filename),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def get_stat_exif_for_file(self, filename):
|
||||
""" get stat info for filename (after exiftool has updated it)
|
||||
returns: tuple of (mode, size, mtime)
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"SELECT exif_mode, exif_size, exif_mtime FROM files WHERE filepath_normalized = ?",
|
||||
(filename,),
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
stats = (None, None, None)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
|
||||
logging.debug(f"get_stat_exif_for_file: {stats}")
|
||||
return stats
|
||||
|
||||
def get_info_for_uuid(self, uuid):
|
||||
""" returns the info JSON struct for a UUID """
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT json_info FROM info WHERE uuid = ?", (uuid,))
|
||||
results = c.fetchone()
|
||||
info = results[0] if results else None
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
info = None
|
||||
|
||||
logging.debug(f"get_info: {uuid}, {info}")
|
||||
return info
|
||||
|
||||
def set_info_for_uuid(self, uuid, info):
|
||||
""" sets the info JSON struct for a UUID """
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
|
||||
(uuid, info),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
logging.debug(f"set_info: {uuid}, {info}")
|
||||
|
||||
def get_exifdata_for_file(self, filename):
|
||||
""" returns the exifdata JSON struct for a file """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"SELECT json_exifdata FROM exifdata WHERE filepath_normalized = ?",
|
||||
(filename,),
|
||||
)
|
||||
results = c.fetchone()
|
||||
exifdata = results[0] if results else None
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
exifdata = None
|
||||
|
||||
logging.debug(f"get_exifdata: {filename}, {exifdata}")
|
||||
return exifdata
|
||||
|
||||
def set_exifdata_for_file(self, filename, exifdata):
|
||||
""" sets the exifdata JSON struct for a file """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
|
||||
(filename, exifdata),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
logging.debug(f"set_exifdata: {filename}, {exifdata}")
|
||||
|
||||
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
""" sets all the data for file and uuid at once
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path))
|
||||
filename_normalized = filename.lower()
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
|
||||
(filename, filename_normalized, uuid),
|
||||
)
|
||||
c.execute(
|
||||
"UPDATE files "
|
||||
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
|
||||
+ "WHERE filepath_normalized = ?;",
|
||||
(*orig_stat, filename_normalized),
|
||||
)
|
||||
c.execute(
|
||||
"UPDATE files "
|
||||
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
|
||||
+ "WHERE filepath_normalized = ?;",
|
||||
(*exif_stat, filename_normalized),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
|
||||
(uuid, info_json),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
|
||||
(filename_normalized, exif_json),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def close(self):
|
||||
""" close the database connection """
|
||||
try:
|
||||
self._conn.close()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def _open_export_db(self, dbfile):
|
||||
""" open export database and return a db connection
|
||||
if dbfile does not exist, will create and initialize the database
|
||||
returns: connection to the database
|
||||
"""
|
||||
|
||||
if not os.path.isfile(dbfile):
|
||||
logging.debug(f"dbfile {dbfile} doesn't exist, creating it")
|
||||
conn = self._get_db_connection(dbfile)
|
||||
if conn:
|
||||
self._create_db_tables(conn)
|
||||
else:
|
||||
raise Exception("Error getting connection to database {dbfile}")
|
||||
else:
|
||||
logging.debug(f"dbfile {dbfile} exists, opening it")
|
||||
conn = self._get_db_connection(dbfile)
|
||||
|
||||
return conn
|
||||
|
||||
def _get_db_connection(self, dbfile):
|
||||
""" return db connection to dbname """
|
||||
try:
|
||||
conn = sqlite3.connect(dbfile)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
conn = None
|
||||
|
||||
return conn
|
||||
|
||||
def _create_db_tables(self, conn):
|
||||
""" create (if not already created) the necessary db tables for the export database
|
||||
conn: sqlite3 db connection
|
||||
"""
|
||||
sql_commands = {
|
||||
"sql_version_table": """ CREATE TABLE IF NOT EXISTS version (
|
||||
id INTEGER PRIMARY KEY,
|
||||
osxphotos TEXT,
|
||||
exportdb TEXT
|
||||
); """,
|
||||
"sql_files_table": """ CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filepath TEXT NOT NULL,
|
||||
filepath_normalized TEXT NOT NULL,
|
||||
uuid TEXT,
|
||||
orig_mode INTEGER,
|
||||
orig_size INTEGER,
|
||||
orig_mtime REAL,
|
||||
exif_mode INTEGER,
|
||||
exif_size INTEGER,
|
||||
exif_mtime REAL
|
||||
); """,
|
||||
"sql_runs_table": """ CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
datetime TEXT,
|
||||
python_path TEXT,
|
||||
script_name TEXT,
|
||||
args TEXT,
|
||||
cwd TEXT
|
||||
); """,
|
||||
"sql_info_table": """ CREATE TABLE IF NOT EXISTS info (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uuid text NOT NULL,
|
||||
json_info JSON
|
||||
); """,
|
||||
"sql_exifdata_table": """ CREATE TABLE IF NOT EXISTS exifdata (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filepath_normalized TEXT NOT NULL,
|
||||
json_exifdata JSON
|
||||
); """,
|
||||
"sql_files_idx": """ CREATE UNIQUE INDEX idx_files_filepath_normalized on files (filepath_normalized); """,
|
||||
"sql_info_idx": """ CREATE UNIQUE INDEX idx_info_uuid on info (uuid); """,
|
||||
"sql_exifdata_idx": """ CREATE UNIQUE INDEX idx_exifdata_filename on exifdata (filepath_normalized); """,
|
||||
}
|
||||
try:
|
||||
c = conn.cursor()
|
||||
for cmd in sql_commands.values():
|
||||
c.execute(cmd)
|
||||
c.execute(
|
||||
"INSERT INTO version(osxphotos, exportdb) VALUES (?, ?);",
|
||||
(__version__, OSXPHOTOS_EXPORTDB_VERSION),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def __del__(self):
|
||||
""" ensure the database connection is closed """
|
||||
if self._conn:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def _insert_run_info(self):
|
||||
dt = datetime.datetime.utcnow().isoformat()
|
||||
python_path = sys.executable
|
||||
cmd = sys.argv[0]
|
||||
args = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else ""
|
||||
cwd = os.getcwd()
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)",
|
||||
(dt, python_path, cmd, args, cwd),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
|
||||
class ExportDBInMemory(ExportDB):
|
||||
""" In memory version of ExportDB
|
||||
Copies the on-disk database into memory so it may be operated on without
|
||||
modifying the on-disk verison
|
||||
"""
|
||||
|
||||
def init(self, dbfile):
|
||||
self._dbfile = dbfile
|
||||
# _path is parent of the database
|
||||
# all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this parent _path
|
||||
# this allows the entire export tree to be moved to a new disk/location
|
||||
# whilst preserving the UUID to filename mappping
|
||||
self._path = pathlib.Path(dbfile).parent
|
||||
self._conn = self._open_export_db(dbfile)
|
||||
self._insert_run_info()
|
||||
|
||||
def _open_export_db(self, dbfile):
|
||||
""" open export database and return a db connection
|
||||
if dbfile does not exist, will create and initialize the database
|
||||
returns: connection to the database
|
||||
"""
|
||||
if not os.path.isfile(dbfile):
|
||||
logging.debug(f"dbfile {dbfile} doesn't exist, creating in memory version")
|
||||
conn = self._get_db_connection()
|
||||
if conn:
|
||||
self._create_db_tables(conn)
|
||||
else:
|
||||
raise Exception("Error getting connection to in-memory database")
|
||||
else:
|
||||
logging.debug(f"dbfile {dbfile} exists, opening it and copying to memory")
|
||||
try:
|
||||
conn = sqlite3.connect(dbfile)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
raise e
|
||||
|
||||
tempfile = StringIO()
|
||||
for line in conn.iterdump():
|
||||
tempfile.write("%s\n" % line)
|
||||
conn.close()
|
||||
tempfile.seek(0)
|
||||
|
||||
# Create a database in memory and import from tempfile
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.cursor().executescript(tempfile.read())
|
||||
conn.commit()
|
||||
|
||||
return conn
|
||||
|
||||
def _get_db_connection(self):
|
||||
""" return db connection to in memory database """
|
||||
try:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
conn = None
|
||||
|
||||
return conn
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.26.0"
|
||||
__version__ = "0.34.2"
|
||||
|
||||
332
osxphotos/albuminfo.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
AlbumInfo and FolderInfo classes for dealing with albums and folders
|
||||
|
||||
AlbumInfo class
|
||||
Represents a single Album in the Photos library and provides access to the album's attributes
|
||||
PhotosDB.albums() returns a list of AlbumInfo objects
|
||||
|
||||
FolderInfo class
|
||||
Represents a single Folder in the Photos library and provides access to the folders attributes
|
||||
PhotosDB.folders() returns a list of FolderInfo objects
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ._constants import (
|
||||
_PHOTOS_4_ALBUM_KIND,
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUM,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_FOLDER_KIND,
|
||||
TIME_DELTA,
|
||||
)
|
||||
from .datetime_utils import get_local_tz
|
||||
|
||||
|
||||
def sort_list_by_keys(values, sort_keys):
|
||||
""" Sorts list values by a second list sort_keys
|
||||
e.g. given ["a","c","b"], [1, 3, 2], returns ["a", "b", "c"]
|
||||
|
||||
Args:
|
||||
values: a list of values to be sorted
|
||||
sort_keys: a list of keys to sort values by
|
||||
|
||||
Returns:
|
||||
list of values, sorted by sort_keys
|
||||
|
||||
Raises:
|
||||
ValueError: raised if len(values) != len(sort_keys)
|
||||
"""
|
||||
if len(values) != len(sort_keys):
|
||||
return ValueError("values and sort_keys must have same length")
|
||||
|
||||
return list(zip(*sorted(zip(sort_keys, values))))[1]
|
||||
|
||||
|
||||
class AlbumInfoBaseClass:
|
||||
"""
|
||||
Base class for AlbumInfo, ImportInfo
|
||||
Info about a specific Album, contains all the details about the album
|
||||
including folders, photos, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, db=None, uuid=None):
|
||||
self._uuid = uuid
|
||||
self._db = db
|
||||
self._title = self._db._dbalbum_details[uuid]["title"]
|
||||
self._creation_date_timestamp = self._db._dbalbum_details[uuid]["creation_date"]
|
||||
self._start_date_timestamp = self._db._dbalbum_details[uuid]["start_date"]
|
||||
self._end_date_timestamp = self._db._dbalbum_details[uuid]["end_date"]
|
||||
self._local_tz = get_local_tz(
|
||||
datetime.fromtimestamp(self._creation_date_timestamp + TIME_DELTA)
|
||||
)
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" return uuid of album """
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def creation_date(self):
|
||||
""" return creation date of album """
|
||||
try:
|
||||
return self._creation_date
|
||||
except AttributeError:
|
||||
try:
|
||||
self._creation_date = (
|
||||
datetime.fromtimestamp(
|
||||
self._creation_date_timestamp + TIME_DELTA
|
||||
).astimezone(tz=self._local_tz)
|
||||
if self._creation_date_timestamp
|
||||
else datetime(1970, 1, 1, 0, 0, 0).astimezone(
|
||||
tz=timezone(timedelta(0))
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
self._creation_date = datetime(1970, 1, 1, 0, 0, 0).astimezone(
|
||||
tz=timezone(timedelta(0))
|
||||
)
|
||||
return self._creation_date
|
||||
|
||||
@property
|
||||
def start_date(self):
|
||||
""" For Albums, return start date (earliest image) of album or None for albums with no images
|
||||
For Import Sessions, return start date of import session (when import began) """
|
||||
try:
|
||||
return self._start_date
|
||||
except AttributeError:
|
||||
try:
|
||||
self._start_date = (
|
||||
datetime.fromtimestamp(
|
||||
self._start_date_timestamp + TIME_DELTA
|
||||
).astimezone(tz=self._local_tz)
|
||||
if self._start_date_timestamp
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
self._start_date = None
|
||||
return self._start_date
|
||||
|
||||
@property
|
||||
def end_date(self):
|
||||
""" For Albums, return end date (most recent image) of album or None for albums with no images
|
||||
For Import Sessions, return end date of import sessions (when import was completed) """
|
||||
try:
|
||||
return self._end_date
|
||||
except AttributeError:
|
||||
try:
|
||||
self._end_date = (
|
||||
datetime.fromtimestamp(
|
||||
self._end_date_timestamp + TIME_DELTA
|
||||
).astimezone(tz=self._local_tz)
|
||||
if self._end_date_timestamp
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
self._end_date = None
|
||||
return self._end_date
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
return []
|
||||
|
||||
def __len__(self):
|
||||
""" return number of photos contained in album """
|
||||
return len(self.photos)
|
||||
|
||||
|
||||
class AlbumInfo(AlbumInfoBaseClass):
|
||||
"""
|
||||
Base class for AlbumInfo, ImportInfo
|
||||
Info about a specific Album, contains all the details about the album
|
||||
including folders, photos, etc.
|
||||
"""
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of album """
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in album sorted in same sort order as Photos """
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
if self.uuid in self._db._dbalbums_album:
|
||||
uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid])
|
||||
sorted_uuid = sort_list_by_keys(uuid, sort_order)
|
||||
self._photos = self._db.photos_by_uuid(sorted_uuid)
|
||||
else:
|
||||
self._photos = []
|
||||
return self._photos
|
||||
|
||||
@property
|
||||
def folder_names(self):
|
||||
""" return hierarchical list of folders the album is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders """
|
||||
|
||||
try:
|
||||
return self._folder_names
|
||||
except AttributeError:
|
||||
self._folder_names = self._db._album_folder_hierarchy_list(self._uuid)
|
||||
return self._folder_names
|
||||
|
||||
@property
|
||||
def folder_list(self):
|
||||
""" return hierarchical list of folders the album is contained in
|
||||
as list of FolderInfo objects in form
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders """
|
||||
|
||||
try:
|
||||
return self._folders
|
||||
except AttributeError:
|
||||
self._folders = self._db._album_folder_hierarchy_folderinfo(self._uuid)
|
||||
return self._folders
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
""" returns FolderInfo object for parent folder or None if no parent (e.g. top-level album) """
|
||||
try:
|
||||
return self._parent
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
parent_uuid = self._db._dbalbum_details[self._uuid]["folderUuid"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=parent_uuid)
|
||||
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
|
||||
else None
|
||||
)
|
||||
else:
|
||||
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
|
||||
if parent_pk != self._db._folder_root_pk
|
||||
else None
|
||||
)
|
||||
return self._parent
|
||||
|
||||
|
||||
class ImportInfo(AlbumInfoBaseClass):
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in import session """
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
uuid_list, sort_order = zip(
|
||||
*[
|
||||
(uuid, self._db._dbphotos[uuid]["fok_import_session"])
|
||||
for uuid in self._db._dbphotos
|
||||
if self._db._dbphotos[uuid]["import_uuid"] == self.uuid
|
||||
]
|
||||
)
|
||||
sorted_uuid = sort_list_by_keys(uuid_list, sort_order)
|
||||
self._photos = self._db.photos_by_uuid(sorted_uuid)
|
||||
return self._photos
|
||||
|
||||
|
||||
class FolderInfo:
|
||||
"""
|
||||
Info about a specific folder, contains all the details about the folder
|
||||
including folders, albums, etc
|
||||
"""
|
||||
|
||||
def __init__(self, db=None, uuid=None):
|
||||
self._uuid = uuid
|
||||
self._db = db
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
self._pk = None
|
||||
self._title = self._db._dbfolder_details[uuid]["name"]
|
||||
else:
|
||||
self._pk = self._db._dbalbum_details[uuid]["pk"]
|
||||
self._title = self._db._dbalbum_details[uuid]["title"]
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of folder"""
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" return uuid of folder """
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def album_info(self):
|
||||
""" return list of albums (as AlbumInfo objects) contained in the folder """
|
||||
try:
|
||||
return self._albums
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
albums = [
|
||||
AlbumInfo(db=self._db, uuid=album)
|
||||
for album, detail in self._db._dbalbum_details.items()
|
||||
if not detail["intrash"]
|
||||
and detail["albumSubclass"] == _PHOTOS_4_ALBUM_KIND
|
||||
and detail["folderUuid"] == self._uuid
|
||||
]
|
||||
else:
|
||||
albums = [
|
||||
AlbumInfo(db=self._db, uuid=album)
|
||||
for album, detail in self._db._dbalbum_details.items()
|
||||
if not detail["intrash"]
|
||||
and detail["kind"] == _PHOTOS_5_ALBUM_KIND
|
||||
and detail["parentfolder"] == self._pk
|
||||
]
|
||||
self._albums = albums
|
||||
return self._albums
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
""" returns FolderInfo object for parent or None if no parent (e.g. top-level folder) """
|
||||
try:
|
||||
return self._parent
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
parent_uuid = self._db._dbfolder_details[self._uuid]["parentFolderUuid"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=parent_uuid)
|
||||
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
|
||||
else None
|
||||
)
|
||||
else:
|
||||
parent_pk = self._db._dbalbum_details[self._uuid]["parentfolder"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=self._db._dbalbums_pk[parent_pk])
|
||||
if parent_pk != self._db._folder_root_pk
|
||||
else None
|
||||
)
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
def subfolders(self):
|
||||
""" return list of folders (as FolderInfo objects) contained in the folder """
|
||||
try:
|
||||
return self._folders
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
folders = [
|
||||
FolderInfo(db=self._db, uuid=folder)
|
||||
for folder, detail in self._db._dbfolder_details.items()
|
||||
if not detail["intrash"]
|
||||
and not detail["isMagic"]
|
||||
and detail["parentFolderUuid"] == self._uuid
|
||||
]
|
||||
else:
|
||||
folders = [
|
||||
FolderInfo(db=self._db, uuid=album)
|
||||
for album, detail in self._db._dbalbum_details.items()
|
||||
if not detail["intrash"]
|
||||
and detail["kind"] == _PHOTOS_5_FOLDER_KIND
|
||||
and detail["parentfolder"] == self._pk
|
||||
]
|
||||
self._folders = folders
|
||||
return self._folders
|
||||
|
||||
def __len__(self):
|
||||
""" returns count of folders + albums contained in the folder """
|
||||
return len(self.subfolders) + len(self.album_info)
|
||||
70
osxphotos/datetime_formatter.py
Normal file
@@ -0,0 +1,70 @@
|
||||
""" Simple formatting of datetime.datetime objects """
|
||||
|
||||
import datetime
|
||||
|
||||
|
||||
class DateTimeFormatter:
|
||||
""" provides property access to formatted datetime.datetime strftime values """
|
||||
|
||||
def __init__(self, dt: datetime.datetime):
|
||||
self.dt = dt
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
""" ISO date in form 2020-03-22 """
|
||||
return self.dt.date().isoformat()
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" 4 digit year """
|
||||
return f"{self.dt.year}"
|
||||
|
||||
@property
|
||||
def yy(self):
|
||||
""" 2 digit year """
|
||||
return f"{self.dt.strftime('%y')}"
|
||||
|
||||
@property
|
||||
def mm(self):
|
||||
""" 2 digit month """
|
||||
return f"{self.dt.strftime('%m')}"
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" Month as locale's full name """
|
||||
return f"{self.dt.strftime('%B')}"
|
||||
|
||||
@property
|
||||
def mon(self):
|
||||
""" Month as locale's abbreviated name """
|
||||
return f"{self.dt.strftime('%b')}"
|
||||
|
||||
@property
|
||||
def dd(self):
|
||||
""" 2-digit day of the month """
|
||||
return f"{self.dt.strftime('%d')}"
|
||||
|
||||
@property
|
||||
def dow(self):
|
||||
""" Day of week as locale's name """
|
||||
return f"{self.dt.strftime('%A')}"
|
||||
|
||||
@property
|
||||
def doy(self):
|
||||
""" Julian day of year starting from 001 """
|
||||
return f"{self.dt.strftime('%j')}"
|
||||
|
||||
@property
|
||||
def hour(self):
|
||||
""" 2-digit hour """
|
||||
return f"{self.dt.strftime('%H')}"
|
||||
|
||||
@property
|
||||
def min(self):
|
||||
""" 2-digit minute """
|
||||
return f"{self.dt.strftime('%M')}"
|
||||
|
||||
@property
|
||||
def sec(self):
|
||||
""" 2-digit second """
|
||||
return f"{self.dt.strftime('%S')}"
|
||||
62
osxphotos/datetime_utils.py
Normal file
@@ -0,0 +1,62 @@
|
||||
""" datetime utilities """
|
||||
|
||||
import datetime
|
||||
|
||||
|
||||
def get_local_tz(dt):
|
||||
""" return local timezone as datetime.timezone tzinfo for dt
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime
|
||||
|
||||
Returns:
|
||||
local timezone for dt as datetime.timezone
|
||||
|
||||
Raises:
|
||||
ValueError if dt is not timezone naive
|
||||
"""
|
||||
if not datetime_has_tz(dt):
|
||||
return dt.astimezone().tzinfo
|
||||
else:
|
||||
raise ValueError("dt must be naive datetime.datetime object")
|
||||
|
||||
|
||||
def datetime_remove_tz(dt):
|
||||
""" remove timezone from a datetime.datetime object
|
||||
dt: datetime.datetime object with tzinfo
|
||||
returns: dt without any timezone info (naive datetime object) """
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
return dt.replace(tzinfo=None)
|
||||
|
||||
|
||||
def datetime_has_tz(dt):
|
||||
""" return True if datetime dt has tzinfo else False
|
||||
dt: datetime.datetime
|
||||
returns True if dt is timezone aware, else False """
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
||||
|
||||
|
||||
def datetime_naive_to_local(dt):
|
||||
""" convert naive (timezone unaware) datetime.datetime
|
||||
to aware timezone in local timezone
|
||||
dt: datetime.datetime without timezone
|
||||
returns: datetime.datetime with local timezone """
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||
# has timezone info
|
||||
raise ValueError(
|
||||
"dt must be naive/timezone unaware: "
|
||||
f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tizinfo.utcoffset(dt)}"
|
||||
)
|
||||
|
||||
return dt.replace(tzinfo=get_local_tz(dt))
|
||||
@@ -8,9 +8,10 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache # pylint: disable=syntax-error
|
||||
from functools import lru_cache # pylint: disable=syntax-error
|
||||
|
||||
from .utils import _debug
|
||||
|
||||
@@ -22,8 +23,7 @@ EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
|
||||
@lru_cache(maxsize=1)
|
||||
def get_exiftool_path():
|
||||
""" return path of exiftool, cache result """
|
||||
result = subprocess.run(["which", "exiftool"], stdout=subprocess.PIPE)
|
||||
exiftool_path = result.stdout.decode("utf-8")
|
||||
exiftool_path = shutil.which('exiftool')
|
||||
if _debug():
|
||||
logging.debug("exiftool path = %s" % (exiftool_path))
|
||||
if exiftool_path:
|
||||
@@ -59,11 +59,7 @@ class _ExifToolProc:
|
||||
)
|
||||
return
|
||||
|
||||
if exiftool:
|
||||
self._exiftool = exiftool
|
||||
else:
|
||||
self._exiftool = get_exiftool_path()
|
||||
|
||||
self._exiftool = exiftool if exiftool else get_exiftool_path()
|
||||
self._process_running = False
|
||||
self._start_proc()
|
||||
|
||||
@@ -156,8 +152,7 @@ class ExifTool:
|
||||
|
||||
if value is None:
|
||||
value = ""
|
||||
command = []
|
||||
command.append(f"-{tag}={value}")
|
||||
command = [f"-{tag}={value}"]
|
||||
if self.overwrite:
|
||||
command.append("-overwrite_original")
|
||||
self.run_commands(*command)
|
||||
@@ -193,7 +188,7 @@ class ExifTool:
|
||||
no_file: (bool) do not pass the filename to exiftool (default=False)
|
||||
by default, all commands will be run against self.file
|
||||
use no_file=True to run a command without passing the filename """
|
||||
if not hasattr(self, "_process") or not self._process:
|
||||
if not (hasattr(self, "_process") and self._process):
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
if not commands:
|
||||
@@ -232,20 +227,25 @@ class ExifTool:
|
||||
ver = self.run_commands("-ver", no_file=True)
|
||||
return ver.decode("utf-8")
|
||||
|
||||
def json(self):
|
||||
""" return JSON dictionary from exiftool as dict """
|
||||
def as_dict(self):
|
||||
""" return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
"""
|
||||
json_str = self.run_commands("-json")
|
||||
if json_str:
|
||||
return json.loads(json_str)
|
||||
exifdict = json.loads(json_str)
|
||||
return exifdict[0]
|
||||
else:
|
||||
return None
|
||||
return dict()
|
||||
|
||||
def json(self):
|
||||
""" returns JSON string containing all EXIF tags and values from exiftool """
|
||||
return self.run_commands("-json")
|
||||
|
||||
def _read_exif(self):
|
||||
""" read exif data from file """
|
||||
json = self.json()
|
||||
self.data = {k: v for k, v in json[0].items()}
|
||||
data = self.as_dict()
|
||||
self.data = {k: v for k, v in data.items()}
|
||||
|
||||
def __str__(self):
|
||||
str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
|
||||
return str_
|
||||
|
||||
return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
|
||||
|
||||
223
osxphotos/fileutil.py
Normal file
@@ -0,0 +1,223 @@
|
||||
""" FileUtil class with methods for copy, hardlink, unlink, etc. """
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class FileUtilABC(ABC):
|
||||
""" Abstract base class for FileUtil """
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def hardlink(cls, src, dest):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def copy(cls, src, dest, norsrc=False):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def unlink(cls, dest):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def utime(cls, path, times):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def cmp(cls, file1, file2, mtime1=None):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def cmp_file_sig(cls, file1, file2):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def file_sig(cls, file1):
|
||||
pass
|
||||
|
||||
|
||||
class FileUtilMacOS(FileUtilABC):
|
||||
""" Various file utilities """
|
||||
|
||||
@classmethod
|
||||
def hardlink(cls, src, dest):
|
||||
""" Hardlinks a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
Raises exception if linking fails or either path is None """
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
os.link(src, dest)
|
||||
except Exception as e:
|
||||
logging.critical(f"os.link returned error: {e}")
|
||||
raise e
|
||||
|
||||
@classmethod
|
||||
def copy(cls, src, dest, norsrc=False):
|
||||
""" Copies a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy
|
||||
resource fork or extended attributes. May be useful on volumes that
|
||||
don't work with extended attributes (likely only certain SMB mounts)
|
||||
default is False
|
||||
Uses ditto to perform copy; will silently overwrite dest if it exists
|
||||
Raises exception if copy fails or either path is None """
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
|
||||
if norsrc:
|
||||
command = ["/usr/bin/ditto", "--norsrc", src, dest]
|
||||
else:
|
||||
command = ["/usr/bin/ditto", src, dest]
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
result = subprocess.run(command, check=True, stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.critical(
|
||||
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
|
||||
)
|
||||
raise e
|
||||
|
||||
return result.returncode
|
||||
|
||||
@classmethod
|
||||
def unlink(cls, filepath):
|
||||
""" unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink """
|
||||
if isinstance(filepath, pathlib.Path):
|
||||
filepath.unlink()
|
||||
else:
|
||||
os.unlink(filepath)
|
||||
|
||||
@classmethod
|
||||
def utime(cls, path, times):
|
||||
""" Set the access and modified time of path. """
|
||||
os.utime(path, times)
|
||||
|
||||
@classmethod
|
||||
def cmp(cls, f1, f2, mtime1=None):
|
||||
"""Does shallow compare (file signatures) of f1 to file f2.
|
||||
Arguments:
|
||||
f1 -- File name
|
||||
f2 -- File name
|
||||
mtime1 -- optional, pass alternate file modification timestamp for f1; will be converted to int
|
||||
|
||||
Return value:
|
||||
True if the file signatures as returned by stat are the same, False otherwise.
|
||||
Does not do a byte-by-byte comparison.
|
||||
"""
|
||||
|
||||
s1 = cls._sig(os.stat(f1))
|
||||
if mtime1 is not None:
|
||||
s1 = (s1[0], s1[1], int(mtime1))
|
||||
s2 = cls._sig(os.stat(f2))
|
||||
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
|
||||
return False
|
||||
return s1 == s2
|
||||
|
||||
@classmethod
|
||||
def cmp_file_sig(cls, f1, s2):
|
||||
"""Compare file f1 to signature s2.
|
||||
Arguments:
|
||||
f1 -- File name
|
||||
s2 -- stats as returned by _sig
|
||||
|
||||
Return value:
|
||||
True if the files are the same, False otherwise.
|
||||
"""
|
||||
|
||||
if not s2:
|
||||
return False
|
||||
|
||||
s1 = cls._sig(os.stat(f1))
|
||||
|
||||
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
|
||||
return False
|
||||
return s1 == s2
|
||||
|
||||
@classmethod
|
||||
def file_sig(cls, f1):
|
||||
""" return os.stat signature for file f1 """
|
||||
return cls._sig(os.stat(f1))
|
||||
|
||||
@staticmethod
|
||||
def _sig(st):
|
||||
""" return tuple of (mode, size, mtime) of file based on os.stat
|
||||
Args:
|
||||
st: os.stat signature
|
||||
"""
|
||||
# use int(st.st_mtime) because ditto does not copy fractional portion of mtime
|
||||
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
|
||||
|
||||
|
||||
class FileUtil(FileUtilMacOS):
|
||||
""" Various file utilities """
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FileUtilNoOp(FileUtil):
|
||||
""" No-Op implementation of FileUtil for testing / dry-run mode
|
||||
all methods with exception of cmp, cmp_file_sig and file_cmp are no-op
|
||||
cmp and cmp_file_sig functions as FileUtil methods do
|
||||
file_cmp returns mock data
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def noop(*args):
|
||||
pass
|
||||
|
||||
verbose = noop
|
||||
|
||||
def __new__(cls, verbose=None):
|
||||
if verbose:
|
||||
if callable(verbose):
|
||||
cls.verbose = verbose
|
||||
else:
|
||||
raise ValueError(f"verbose {verbose} not callable")
|
||||
return super(FileUtilNoOp, cls).__new__(cls)
|
||||
|
||||
@classmethod
|
||||
def hardlink(cls, src, dest):
|
||||
cls.verbose(f"hardlink: {src} {dest}")
|
||||
|
||||
@classmethod
|
||||
def copy(cls, src, dest, norsrc=False):
|
||||
cls.verbose(f"copy: {src} {dest}")
|
||||
|
||||
@classmethod
|
||||
def unlink(cls, dest):
|
||||
cls.verbose(f"unlink: {dest}")
|
||||
|
||||
@classmethod
|
||||
def utime(cls, path, times):
|
||||
cls.verbose(f"utime: {path}, {times}")
|
||||
|
||||
@classmethod
|
||||
def file_sig(cls, file1):
|
||||
cls.verbose(f"file_sig: {file1}")
|
||||
return (42, 42, 42)
|
||||
408
osxphotos/personinfo.py
Normal file
@@ -0,0 +1,408 @@
|
||||
""" PhotoInfo and FaceInfo classes to expose info about persons and faces in the Photos library """
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
|
||||
|
||||
class PersonInfo:
|
||||
""" Info about a person in the Photos library
|
||||
"""
|
||||
|
||||
def __init__(self, db=None, pk=None):
|
||||
""" Creates a new PersonInfo instance
|
||||
|
||||
Arguments:
|
||||
db: instance of PhotosDB object
|
||||
pk: primary key value of person to initialize PersonInfo with
|
||||
|
||||
Returns:
|
||||
PersonInfo instance
|
||||
"""
|
||||
self._db = db
|
||||
self._pk = pk
|
||||
|
||||
person = self._db._dbpersons_pk[pk]
|
||||
self.uuid = person["uuid"]
|
||||
self.name = person["fullname"]
|
||||
self.display_name = person["displayname"]
|
||||
self.keyface = person["keyface"]
|
||||
self.facecount = person["facecount"]
|
||||
|
||||
@property
|
||||
def keyphoto(self):
|
||||
try:
|
||||
return self._keyphoto
|
||||
except AttributeError:
|
||||
person = self._db._dbpersons_pk[self._pk]
|
||||
if person["photo_uuid"]:
|
||||
try:
|
||||
key_photo = self._db.get_photo(person["photo_uuid"])
|
||||
except IndexError:
|
||||
key_photo = None
|
||||
else:
|
||||
key_photo = None
|
||||
self._keyphoto = key_photo
|
||||
return self._keyphoto
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
""" Returns list of PhotoInfo objects associated with this person """
|
||||
return self._db.photos_by_uuid(self._db._dbfaces_pk[self._pk])
|
||||
|
||||
@property
|
||||
def face_info(self):
|
||||
""" Returns a list of FaceInfo objects associated with this person sorted by quality score
|
||||
Highest quality face is result[0] and lowest quality face is result[n]
|
||||
"""
|
||||
try:
|
||||
faces = self._db._db_faceinfo_person[self._pk]
|
||||
return sorted(
|
||||
[FaceInfo(db=self._db, pk=face) for face in faces],
|
||||
key=lambda face: face.quality,
|
||||
reverse=True,
|
||||
)
|
||||
except KeyError:
|
||||
# no faces
|
||||
return []
|
||||
|
||||
def json(self):
|
||||
""" Returns JSON representation of class instance """
|
||||
keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None
|
||||
person = {
|
||||
"uuid": self.uuid,
|
||||
"name": self.name,
|
||||
"displayname": self.display_name,
|
||||
"keyface": self.keyface,
|
||||
"facecount": self.facecount,
|
||||
"keyphoto": keyphoto,
|
||||
}
|
||||
return json.dumps(person)
|
||||
|
||||
def __str__(self):
|
||||
return f"PersonInfo(name={self.name}, display_name={self.display_name}, uuid={self.uuid}, facecount={self.facecount})"
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, type(self)):
|
||||
return False
|
||||
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
|
||||
class FaceInfo:
|
||||
""" Info about a face in the Photos library
|
||||
"""
|
||||
|
||||
def __init__(self, db=None, pk=None):
|
||||
""" Creates a new FaceInfo instance
|
||||
|
||||
Arguments:
|
||||
db: instance of PhotosDB object
|
||||
pk: primary key value of face to init the object with
|
||||
|
||||
Returns:
|
||||
FaceInfo instance
|
||||
"""
|
||||
self._db = db
|
||||
self._pk = pk
|
||||
|
||||
face = self._db._db_faceinfo_pk[pk]
|
||||
self._info = face
|
||||
self.uuid = face["uuid"]
|
||||
self.name = face["fullname"]
|
||||
self.asset_uuid = face["asset_uuid"]
|
||||
self._person_pk = face["person"]
|
||||
self.center_x = face["centerx"]
|
||||
self.center_y = face["centery"]
|
||||
self.mouth_x = face["mouthx"]
|
||||
self.mouth_y = face["mouthy"]
|
||||
self.left_eye_x = face["lefteyex"]
|
||||
self.left_eye_y = face["lefteyey"]
|
||||
self.right_eye_x = face["righteyex"]
|
||||
self.right_eye_y = face["righteyey"]
|
||||
self.size = face["size"]
|
||||
self.quality = face["quality"]
|
||||
self.source_width = face["sourcewidth"]
|
||||
self.source_height = face["sourceheight"]
|
||||
self.has_smile = face["has_smile"]
|
||||
self.left_eye_closed = face["left_eye_closed"]
|
||||
self.right_eye_closed = face["right_eye_closed"]
|
||||
self.manual = face["manual"]
|
||||
self.face_type = face["facetype"]
|
||||
self.age_type = face["agetype"]
|
||||
self.bald_type = face["baldtype"]
|
||||
self.eye_makeup_type = face["eyemakeuptype"]
|
||||
self.eye_state = face["eyestate"]
|
||||
self.facial_hair_type = face["facialhairtype"]
|
||||
self.gender_type = face["gendertype"]
|
||||
self.glasses_type = face["glassestype"]
|
||||
self.hair_color_type = face["haircolortype"]
|
||||
self.intrash = face["intrash"]
|
||||
self.lip_makeup_type = face["lipmakeuptype"]
|
||||
self.smile_type = face["smiletype"]
|
||||
|
||||
@property
|
||||
def center(self):
|
||||
""" Coordinates, in PIL format, for center of face
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
"""
|
||||
return self._make_point((self.center_x, self.center_y))
|
||||
|
||||
@property
|
||||
def size_pixels(self):
|
||||
""" Size of face in pixels (centered around center_x, center_y)
|
||||
|
||||
Returns:
|
||||
size, in int pixels, of a circle drawn around the center of the face
|
||||
"""
|
||||
photo = self.photo
|
||||
size_reference = photo.width if photo.width > photo.height else photo.height
|
||||
return self.size * size_reference
|
||||
|
||||
@property
|
||||
def mouth(self):
|
||||
""" Coordinates, in PIL format, for mouth position
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
"""
|
||||
return self._make_point_with_rotation((self.mouth_x, self.mouth_y))
|
||||
|
||||
@property
|
||||
def left_eye(self):
|
||||
""" Coordinates, in PIL format, for left eye position
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
"""
|
||||
return self._make_point_with_rotation((self.left_eye_x, self.left_eye_y))
|
||||
|
||||
@property
|
||||
def right_eye(self):
|
||||
""" Coordinates, in PIL format, for right eye position
|
||||
|
||||
Returns:
|
||||
tuple of coordinates in form (x, y)
|
||||
"""
|
||||
return self._make_point_with_rotation((self.right_eye_x, self.right_eye_y))
|
||||
|
||||
@property
|
||||
def person_info(self):
|
||||
""" PersonInfo instance for person associated with this face """
|
||||
try:
|
||||
return self._person
|
||||
except AttributeError:
|
||||
self._person = PersonInfo(db=self._db, pk=self._person_pk)
|
||||
return self._person
|
||||
|
||||
@property
|
||||
def photo(self):
|
||||
""" PhotoInfo instance associated with this face """
|
||||
try:
|
||||
return self._photo
|
||||
except AttributeError:
|
||||
self._photo = self._db.get_photo(self.asset_uuid)
|
||||
if self._photo is None:
|
||||
logging.warning(f"Could not get photo for uuid: {self.asset_uuid}")
|
||||
return self._photo
|
||||
|
||||
def face_rect(self):
|
||||
""" Get face rectangle coordinates for current version of the associated image
|
||||
If image has been edited, rectangle applies to edited version, otherwise original version
|
||||
Coordinates in format and reference frame used by PIL
|
||||
|
||||
Returns:
|
||||
list [(x0, x1), (y0, y1)] of coordinates in reference frame used by PIL
|
||||
"""
|
||||
photo = self.photo
|
||||
size_reference = photo.width if photo.width > photo.height else photo.height
|
||||
radius = (self.size / 2) * size_reference
|
||||
x, y = self._make_point((self.center_x, self.center_y))
|
||||
x0, y0 = x - radius, y - radius
|
||||
x1, y1 = x + radius, y + radius
|
||||
return [(x0, y0), (x1, y1)]
|
||||
|
||||
def roll_pitch_yaw(self):
|
||||
""" Roll, pitch, yaw of face in radians as tuple """
|
||||
info = self._info
|
||||
roll = 0 if info["roll"] is None else info["roll"]
|
||||
pitch = 0 if info["pitch"] is None else info["pitch"]
|
||||
yaw = 0 if info["yaw"] is None else info["yaw"]
|
||||
|
||||
return (roll, pitch, yaw)
|
||||
|
||||
@property
|
||||
def roll(self):
|
||||
""" Return roll angle in radians of the face region """
|
||||
roll, _, _ = self.roll_pitch_yaw()
|
||||
return roll
|
||||
|
||||
@property
|
||||
def pitch(self):
|
||||
""" Return pitch angle in radians of the face region """
|
||||
_, pitch, _ = self.roll_pitch_yaw()
|
||||
return pitch
|
||||
|
||||
@property
|
||||
def yaw(self):
|
||||
""" Return yaw angle in radians of the face region """
|
||||
_, _, yaw = self.roll_pitch_yaw()
|
||||
return yaw
|
||||
|
||||
def _make_point(self, xy):
|
||||
""" Translate an (x, y) tuple based on image orientation
|
||||
and convert to image coordinates
|
||||
|
||||
Arguments:
|
||||
xy: tuple of (x, y) coordinates for point to translate
|
||||
in format used by Photos (percent of height/width)
|
||||
|
||||
Returns:
|
||||
(x, y) tuple of translated coordinates in pixels in PIL format/reference frame
|
||||
"""
|
||||
# Reference: https://github.com/neilpa/phace/blob/7594776480505d0c389688a42099c94ac5d34f3f/cmd/phace/draw.go#L79-L94
|
||||
|
||||
orientation = self.photo.orientation
|
||||
x, y = xy
|
||||
dx = self.photo.width
|
||||
dy = self.photo.height
|
||||
if orientation in [1, 2]:
|
||||
y = 1.0 - y
|
||||
elif orientation in [3, 4]:
|
||||
x = 1.0 - x
|
||||
elif orientation in [5, 6]:
|
||||
x, y = 1.0 - y, 1.0 - x
|
||||
dx, dy = dy, dx
|
||||
elif orientation in [7, 8]:
|
||||
x, y = y, x
|
||||
dx, dy = dy, dx
|
||||
else:
|
||||
logging.warning(f"Unhandled orientation: {orientation}")
|
||||
|
||||
return (int(x * dx), int(y * dy))
|
||||
|
||||
def _make_point_with_rotation(self, xy):
|
||||
""" Translate an (x, y) tuple based on image orientation and rotation
|
||||
and convert to image coordinates
|
||||
|
||||
Arguments:
|
||||
xy: tuple of (x, y) coordinates for point to translate
|
||||
in format used by Photos (percent of height/width)
|
||||
|
||||
Returns:
|
||||
(x, y) tuple of translated coordinates in pixels in PIL format/reference frame
|
||||
"""
|
||||
|
||||
# convert to image coordinates
|
||||
x, y = self._make_point(xy)
|
||||
|
||||
# rotate about center
|
||||
xmid, ymid = self.center
|
||||
roll, _, _ = self.roll_pitch_yaw()
|
||||
xr, yr = rotate_image_point(x, y, xmid, ymid, roll)
|
||||
|
||||
return (int(xr), int(yr))
|
||||
|
||||
def asdict(self):
|
||||
""" Returns dict representation of class instance """
|
||||
roll, pitch, yaw = self.roll_pitch_yaw()
|
||||
return {
|
||||
"_pk": self._pk,
|
||||
"uuid": self.uuid,
|
||||
"name": self.name,
|
||||
"asset_uuid": self.asset_uuid,
|
||||
"_person_pk": self._person_pk,
|
||||
"center_x": self.center_x,
|
||||
"center_y": self.center_y,
|
||||
"center": self.center,
|
||||
"mouth_x": self.mouth_x,
|
||||
"mouth_y": self.mouth_y,
|
||||
"mouth": self.mouth,
|
||||
"left_eye_x": self.left_eye_x,
|
||||
"left_eye_y": self.left_eye_y,
|
||||
"left_eye": self.left_eye,
|
||||
"right_eye_x": self.right_eye_x,
|
||||
"right_eye_y": self.right_eye_y,
|
||||
"right_eye": self.right_eye,
|
||||
"size": self.size,
|
||||
"face_rect": self.face_rect(),
|
||||
"roll": roll,
|
||||
"pitch": pitch,
|
||||
"yaw": yaw,
|
||||
"quality": self.quality,
|
||||
"source_width": self.source_width,
|
||||
"source_height": self.source_height,
|
||||
"has_smile": self.has_smile,
|
||||
"left_eye_closed": self.left_eye_closed,
|
||||
"right_eye_closed": self.right_eye_closed,
|
||||
"manual": self.manual,
|
||||
"face_type": self.face_type,
|
||||
"age_type": self.age_type,
|
||||
"bald_type": self.bald_type,
|
||||
"eye_makeup_type": self.eye_makeup_type,
|
||||
"eye_state": self.eye_state,
|
||||
"facial_hair_type": self.facial_hair_type,
|
||||
"gender_type": self.gender_type,
|
||||
"glasses_type": self.glasses_type,
|
||||
"hair_color_type": self.hair_color_type,
|
||||
"intrash": self.intrash,
|
||||
"lip_makeup_type": self.lip_makeup_type,
|
||||
"smile_type": self.smile_type,
|
||||
}
|
||||
|
||||
def json(self):
|
||||
""" Return JSON representation of FaceInfo instance """
|
||||
return json.dumps(self.asdict())
|
||||
|
||||
def __str__(self):
|
||||
return f"FaceInfo(uuid={self.uuid}, center_x={self.center_x}, center_y = {self.center_y}, size={self.size}, person={self.name}, asset_uuid={self.asset_uuid})"
|
||||
|
||||
def __repr__(self):
|
||||
return f"FaceInfo(db={self._db}, pk={self._pk})"
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, type(self)):
|
||||
return False
|
||||
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field) for field in ["_db", "_pk"]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
|
||||
def rotate_image_point(x, y, xmid, ymid, angle):
|
||||
""" rotate image point about xm, ym by angle in radians
|
||||
|
||||
Arguments:
|
||||
x: x coordinate of point to rotate
|
||||
y: y coordinate of point to rotate
|
||||
xmid: x coordinate of center point to rotate about
|
||||
ymid: y coordinate of center point to rotate about
|
||||
angle: angle in radians about which to coordinate,
|
||||
counter-clockwise is positive
|
||||
|
||||
Returns:
|
||||
tuple of rotated points (xr, yr)
|
||||
"""
|
||||
# translate point relative to the mid point
|
||||
x = x - xmid
|
||||
y = y - ymid
|
||||
|
||||
# rotate by angle and translate back
|
||||
# the photo coordinate system is downwards y is positive so
|
||||
# need to adjust the rotation accordingly
|
||||
cos_angle = math.cos(angle)
|
||||
sin_angle = math.sin(angle)
|
||||
xr = x * cos_angle + y * sin_angle + xmid
|
||||
yr = -x * sin_angle + y * cos_angle + ymid
|
||||
|
||||
return (xr, yr)
|
||||
10
osxphotos/photoinfo/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
PhotoInfo class
|
||||
Represents a single photo in the Photos library and provides access to the photo's attributes
|
||||
PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
from ._photoinfo_exifinfo import ExifInfo
|
||||
from ._photoinfo_export import ExportResults
|
||||
from ._photoinfo_scoreinfo import ScoreInfo
|
||||
from .photoinfo import PhotoInfo
|
||||
94
osxphotos/photoinfo/_photoinfo_exifinfo.py
Normal file
@@ -0,0 +1,94 @@
|
||||
""" PhotoInfo methods to expose EXIF info from the library """
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExifInfo:
|
||||
""" EXIF info associated with a photo from the Photos library """
|
||||
|
||||
flash_fired: bool
|
||||
iso: int
|
||||
metering_mode: int
|
||||
sample_rate: int
|
||||
track_format: int
|
||||
white_balance: int
|
||||
aperture: float
|
||||
bit_rate: float
|
||||
duration: float
|
||||
exposure_bias: float
|
||||
focal_length: float
|
||||
fps: float
|
||||
latitude: float
|
||||
longitude: float
|
||||
shutter_speed: float
|
||||
camera_make: str
|
||||
camera_model: str
|
||||
codec: str
|
||||
lens_model: str
|
||||
|
||||
|
||||
@property
|
||||
def exif_info(self):
|
||||
""" Returns an ExifInfo object with the EXIF data for photo
|
||||
Note: the returned EXIF data is the data Photos stores in the database on import;
|
||||
ExifInfo does not provide access to the EXIF info in the actual image file
|
||||
Some or all of the fields may be None
|
||||
Only valid for Photos 5; on earlier database returns None
|
||||
"""
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.debug(f"exif_info not implemented for this database version")
|
||||
return None
|
||||
|
||||
try:
|
||||
exif = self._db._db_exifinfo_uuid[self.uuid]
|
||||
exif_info = ExifInfo(
|
||||
iso=exif["ZISO"],
|
||||
flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
|
||||
metering_mode=exif["ZMETERINGMODE"],
|
||||
sample_rate=exif["ZSAMPLERATE"],
|
||||
track_format=exif["ZTRACKFORMAT"],
|
||||
white_balance=exif["ZWHITEBALANCE"],
|
||||
aperture=exif["ZAPERTURE"],
|
||||
bit_rate=exif["ZBITRATE"],
|
||||
duration=exif["ZDURATION"],
|
||||
exposure_bias=exif["ZEXPOSUREBIAS"],
|
||||
focal_length=exif["ZFOCALLENGTH"],
|
||||
fps=exif["ZFPS"],
|
||||
latitude=exif["ZLATITUDE"],
|
||||
longitude=exif["ZLONGITUDE"],
|
||||
shutter_speed=exif["ZSHUTTERSPEED"],
|
||||
camera_make=exif["ZCAMERAMAKE"],
|
||||
camera_model=exif["ZCAMERAMODEL"],
|
||||
codec=exif["ZCODEC"],
|
||||
lens_model=exif["ZLENSMODEL"],
|
||||
)
|
||||
except KeyError:
|
||||
logging.debug(f"Could not find exif record for uuid {self.uuid}")
|
||||
exif_info = ExifInfo(
|
||||
iso=None,
|
||||
flash_fired=None,
|
||||
metering_mode=None,
|
||||
sample_rate=None,
|
||||
track_format=None,
|
||||
white_balance=None,
|
||||
aperture=None,
|
||||
bit_rate=None,
|
||||
duration=None,
|
||||
exposure_bias=None,
|
||||
focal_length=None,
|
||||
fps=None,
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
shutter_speed=None,
|
||||
camera_make=None,
|
||||
camera_model=None,
|
||||
codec=None,
|
||||
lens_model=None,
|
||||
)
|
||||
|
||||
return exif_info
|
||||
35
osxphotos/photoinfo/_photoinfo_exiftool.py
Normal file
@@ -0,0 +1,35 @@
|
||||
""" Implementation for PhotoInfo.exiftool property which returns ExifTool object for a photo """
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..exiftool import ExifTool, get_exiftool_path
|
||||
|
||||
|
||||
@property
|
||||
def exiftool(self):
|
||||
""" Returns an ExifTool object for the photo
|
||||
requires that exiftool (https://exiftool.org/) be installed
|
||||
If exiftool not installed, logs warning and returns None
|
||||
If photo path is missing, returns None
|
||||
"""
|
||||
try:
|
||||
# return the memoized instance if it exists
|
||||
return self._exiftool
|
||||
except AttributeError:
|
||||
try:
|
||||
exiftool_path = get_exiftool_path()
|
||||
if self.path is not None and os.path.isfile(self.path):
|
||||
exiftool = ExifTool(self.path)
|
||||
else:
|
||||
exiftool = None
|
||||
logging.debug(f"exiftool: missing path {self.uuid}")
|
||||
except FileNotFoundError:
|
||||
# get_exiftool_path raises FileNotFoundError if exiftool not found
|
||||
exiftool = None
|
||||
logging.warning(
|
||||
f"exiftool not in path; download and install from https://exiftool.org/"
|
||||
)
|
||||
|
||||
self._exiftool = exiftool
|
||||
return self._exiftool
|
||||
1227
osxphotos/photoinfo/_photoinfo_export.py
Normal file
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
|
||||
93
osxphotos/photoinfo/_photoinfo_searchinfo.py
Normal file
@@ -0,0 +1,93 @@
|
||||
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
|
||||
Adds the following properties to PhotoInfo (valid only for Photos 5):
|
||||
search_info: returns a SearchInfo object
|
||||
labels: returns list of labels
|
||||
labels_normalized: returns list of normalized labels
|
||||
"""
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
|
||||
|
||||
|
||||
@property
|
||||
def search_info(self):
|
||||
""" returns SearchInfo object for photo
|
||||
only valid on Photos 5, on older libraries, returns None
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
# memoize SearchInfo object
|
||||
try:
|
||||
return self._search_info
|
||||
except AttributeError:
|
||||
self._search_info = SearchInfo(self)
|
||||
return self._search_info
|
||||
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" returns list of labels applied to photo by Photos image categorization
|
||||
only valid on Photos 5, on older libraries returns empty list
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info.labels
|
||||
|
||||
|
||||
@property
|
||||
def labels_normalized(self):
|
||||
""" returns normalized list of labels applied to photo by Photos image categorization
|
||||
only valid on Photos 5, on older libraries returns empty list
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info.labels_normalized
|
||||
|
||||
|
||||
class SearchInfo:
|
||||
""" Info about search terms such as machine learning labels that Photos knows about a photo """
|
||||
|
||||
def __init__(self, photo):
|
||||
""" photo: PhotoInfo object """
|
||||
|
||||
if photo._db._db_version <= _PHOTOS_4_VERSION:
|
||||
raise NotImplementedError(
|
||||
f"search info not implemented for this database version"
|
||||
)
|
||||
|
||||
self._photo = photo
|
||||
self.uuid = photo.uuid
|
||||
try:
|
||||
# get search info for this UUID
|
||||
# there might not be any search info data (e.g. if Photo was missing or photoanalysisd not run yet)
|
||||
self._db_searchinfo = photo._db._db_searchinfo_uuid[self.uuid]
|
||||
except KeyError:
|
||||
self._db_searchinfo = None
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" return list of labels associated with Photo """
|
||||
if self._db_searchinfo:
|
||||
labels = [
|
||||
rec["content_string"]
|
||||
for rec in self._db_searchinfo
|
||||
if rec["category"] == SEARCH_CATEGORY_LABEL
|
||||
]
|
||||
else:
|
||||
labels = []
|
||||
return labels
|
||||
|
||||
@property
|
||||
def labels_normalized(self):
|
||||
""" return list of normalized labels associated with Photo """
|
||||
if self._db_searchinfo:
|
||||
labels = [
|
||||
rec["normalized_string"]
|
||||
for rec in self._db_searchinfo
|
||||
if rec["category"] == SEARCH_CATEGORY_LABEL
|
||||
]
|
||||
else:
|
||||
labels = []
|
||||
return labels
|
||||
@@ -4,36 +4,32 @@ Represents a single photo in the Photos library and provides access to the photo
|
||||
PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
import glob
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import timedelta, timezone
|
||||
from pprint import pformat
|
||||
|
||||
import yaml
|
||||
from mako.template import Template
|
||||
|
||||
from ._constants import (
|
||||
from .._constants import (
|
||||
_MOVIE_TYPE,
|
||||
_PHOTO_TYPE,
|
||||
_PHOTOS_4_ALBUM_KIND,
|
||||
_PHOTOS_4_ROOT_FOLDER,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
|
||||
_PHOTOS_5_SHARED_ALBUM_KIND,
|
||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
_PHOTOS_5_VERSION,
|
||||
_TEMPLATE_DIR,
|
||||
_XMP_TEMPLATE_NAME,
|
||||
)
|
||||
from .exiftool import ExifTool
|
||||
from .placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from .utils import (
|
||||
_copy_file,
|
||||
_export_photo_uuid_applescript,
|
||||
_get_resource_loc,
|
||||
dd_to_dms_str,
|
||||
)
|
||||
from ..albuminfo import AlbumInfo, ImportInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
from ..phototemplate import PhotoTemplate
|
||||
from ..placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
|
||||
|
||||
|
||||
class PhotoInfo:
|
||||
@@ -42,6 +38,27 @@ class PhotoInfo:
|
||||
including keywords, persons, albums, uuid, path, etc.
|
||||
"""
|
||||
|
||||
# import additional methods
|
||||
from ._photoinfo_searchinfo import (
|
||||
search_info,
|
||||
labels,
|
||||
labels_normalized,
|
||||
SearchInfo,
|
||||
)
|
||||
from ._photoinfo_exifinfo import exif_info, ExifInfo
|
||||
from ._photoinfo_exiftool import exiftool
|
||||
from ._photoinfo_export import (
|
||||
export,
|
||||
export2,
|
||||
_export_photo,
|
||||
_exiftool_json_sidecar,
|
||||
_write_exif_data,
|
||||
_write_sidecar,
|
||||
_xmp_sidecar,
|
||||
ExportResults,
|
||||
)
|
||||
from ._photoinfo_scoreinfo import score, ScoreInfo
|
||||
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self._uuid = uuid
|
||||
self._info = info
|
||||
@@ -50,7 +67,13 @@ class PhotoInfo:
|
||||
@property
|
||||
def filename(self):
|
||||
""" filename of the picture """
|
||||
return self._info["filename"]
|
||||
# sourcery off
|
||||
if self.has_raw and self.raw_original:
|
||||
# return name of the RAW file
|
||||
# TODO: not yet implemented
|
||||
return self._info["filename"]
|
||||
else:
|
||||
return self._info["filename"]
|
||||
|
||||
@property
|
||||
def original_filename(self):
|
||||
@@ -61,12 +84,7 @@ class PhotoInfo:
|
||||
@property
|
||||
def date(self):
|
||||
""" image creation date as timezone aware datetime object """
|
||||
imagedate = self._info["imageDate"]
|
||||
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
imagedate_utc = imagedate.astimezone(tz=tz)
|
||||
return imagedate_utc
|
||||
return self._info["imageDate"]
|
||||
|
||||
@property
|
||||
def date_modified(self):
|
||||
@@ -77,8 +95,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
|
||||
|
||||
@@ -95,7 +112,7 @@ class PhotoInfo:
|
||||
if self._info["isMissing"] == 1:
|
||||
return photopath # path would be meaningless until downloaded
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
vol = self._info["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
|
||||
@@ -134,7 +151,7 @@ class PhotoInfo:
|
||||
|
||||
photopath = None
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self._info["hasAdjustments"]:
|
||||
edit_id = self._info["edit_resource_id"]
|
||||
if edit_id is not None:
|
||||
@@ -234,7 +251,77 @@ class PhotoInfo:
|
||||
# if self._info["isMissing"] == 1:
|
||||
# photopath = None # path would be meaningless until downloaded
|
||||
|
||||
logging.debug(photopath)
|
||||
# logging.debug(photopath)
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def path_raw(self):
|
||||
""" absolute path of associated RAW image or None if there is not one """
|
||||
|
||||
# In Photos 5, raw is in same folder as original but with _4.ext
|
||||
# Unless "Copy Items to the Photos Library" is not checked
|
||||
# then RAW image is not renamed but has same name is jpeg buth with raw extension
|
||||
# Current implementation uses findfiles to find images with the correct raw UTI extension
|
||||
# in same folder as the original and with same stem as original in form: original_stem*.raw_ext
|
||||
# TODO: I don't like this -- would prefer a more deterministic approach but until I have more
|
||||
# data on how Photos stores and retrieves RAW images, this seems to be working
|
||||
|
||||
if self._info["isMissing"] == 1:
|
||||
return None # path would be meaningless until downloaded
|
||||
|
||||
if not self.has_raw:
|
||||
return None # no raw image to get path for
|
||||
|
||||
# if self._info["shared"]:
|
||||
# # shared photo
|
||||
# photopath = os.path.join(
|
||||
# self._db._library_path,
|
||||
# _PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
# self._info["directory"],
|
||||
# self._info["filename"],
|
||||
# )
|
||||
# return photopath
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
vol = self._info["raw_info"]["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join(
|
||||
"/Volumes", vol, self._info["raw_info"]["imagePath"]
|
||||
)
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["raw_info"]["imagePath"]
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
logging.debug(
|
||||
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
filestem = pathlib.Path(self._info["filename"]).stem
|
||||
raw_ext = get_preferred_uti_extension(self._info["UTI_raw"])
|
||||
|
||||
if self._info["directory"].startswith("/"):
|
||||
filepath = self._info["directory"]
|
||||
else:
|
||||
filepath = os.path.join(self._db._masters_path, self._info["directory"])
|
||||
|
||||
glob_str = f"{filestem}*.{raw_ext}"
|
||||
raw_file = findfiles(glob_str, filepath)
|
||||
if len(raw_file) != 1:
|
||||
# Note: In Photos Version 5.0 (141.19.150), images not copied to Photos Library
|
||||
# that are missing do not always trigger is_missing = True as happens
|
||||
# in earlier version so it's possible for this check to fail, if so, return None
|
||||
logging.debug(f"Error getting path to RAW file: {filepath}/{glob_str}")
|
||||
photopath = None
|
||||
else:
|
||||
photopath = os.path.join(filepath, raw_file[0])
|
||||
if not os.path.isfile(photopath):
|
||||
logging.debug(
|
||||
f"MISSING PATH: RAW photo for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
|
||||
return photopath
|
||||
|
||||
@@ -246,15 +333,69 @@ class PhotoInfo:
|
||||
@property
|
||||
def persons(self):
|
||||
""" list of persons in picture """
|
||||
return self._info["persons"]
|
||||
return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]]
|
||||
|
||||
@property
|
||||
def person_info(self):
|
||||
""" list of PersonInfo objects for person in picture """
|
||||
try:
|
||||
return self._personinfo
|
||||
except AttributeError:
|
||||
self._personinfo = [
|
||||
PersonInfo(db=self._db, pk=pk) for pk in self._info["persons"]
|
||||
]
|
||||
return self._personinfo
|
||||
|
||||
@property
|
||||
def face_info(self):
|
||||
""" list of FaceInfo objects for faces in picture """
|
||||
try:
|
||||
return self._faceinfo
|
||||
except AttributeError:
|
||||
try:
|
||||
faces = self._db._db_faceinfo_uuid[self._uuid]
|
||||
self._faceinfo = [FaceInfo(db=self._db, pk=pk) for pk in faces]
|
||||
except KeyError:
|
||||
# no faces
|
||||
self._faceinfo = []
|
||||
return self._faceinfo
|
||||
|
||||
@property
|
||||
def albums(self):
|
||||
""" list of albums picture is contained in """
|
||||
albums = []
|
||||
for album in self._info["albums"]:
|
||||
albums.append(self._db._dbalbum_details[album]["title"])
|
||||
return albums
|
||||
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 """
|
||||
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 import_info(self):
|
||||
""" ImportInfo object representing import session for the photo or None if no import session """
|
||||
try:
|
||||
return self._import_info
|
||||
except AttributeError:
|
||||
self._import_info = (
|
||||
ImportInfo(db=self._db, uuid=self._info["import_uuid"])
|
||||
if self._info["import_uuid"] is not None
|
||||
else None
|
||||
)
|
||||
return self._import_info
|
||||
|
||||
@property
|
||||
def keywords(self):
|
||||
@@ -308,6 +449,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 """
|
||||
@@ -317,7 +463,7 @@ class PhotoInfo:
|
||||
def shared(self):
|
||||
""" returns True if photos is in a shared iCloud album otherwise false
|
||||
Only valid on Photos 5; returns None on older versions """
|
||||
if self._db._db_version >= _PHOTOS_5_VERSION:
|
||||
if self._db._db_version > _PHOTOS_4_VERSION:
|
||||
return self._info["shared"]
|
||||
else:
|
||||
return None
|
||||
@@ -329,6 +475,14 @@ class PhotoInfo:
|
||||
"""
|
||||
return self._info["UTI"]
|
||||
|
||||
@property
|
||||
def uti_raw(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the RAW image if there is one
|
||||
for example: com.canon.cr2-raw-image
|
||||
Returns None if no associated RAW image
|
||||
"""
|
||||
return self._info["UTI_raw"]
|
||||
|
||||
@property
|
||||
def ismovie(self):
|
||||
""" Returns True if file is a movie, otherwise False
|
||||
@@ -354,7 +508,7 @@ class PhotoInfo:
|
||||
""" Returns True if photo is a cloud asset (in an iCloud library),
|
||||
otherwise False
|
||||
"""
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return (
|
||||
True
|
||||
if self._info["cloudLibraryState"] is not None
|
||||
@@ -376,12 +530,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 []
|
||||
|
||||
@@ -397,7 +550,7 @@ class PhotoInfo:
|
||||
If photo is missing, returns None """
|
||||
|
||||
photopath = None
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self.live_photo and not self.ismissing:
|
||||
live_model_id = self._info["live_model_id"]
|
||||
if live_model_id == None:
|
||||
@@ -488,7 +641,7 @@ class PhotoInfo:
|
||||
# implementation note: doesn't create the PlaceInfo object until requested
|
||||
# then memoizes the object in self._place to avoid recreating the object
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
try:
|
||||
return self._place # pylint: disable=access-member-before-definition
|
||||
except AttributeError:
|
||||
@@ -509,374 +662,84 @@ class PhotoInfo:
|
||||
self._place = None
|
||||
return self._place
|
||||
|
||||
def export(
|
||||
@property
|
||||
def has_raw(self):
|
||||
""" returns True if photo has an associated RAW image, otherwise False """
|
||||
return self._info["has_raw"]
|
||||
|
||||
@property
|
||||
def raw_original(self):
|
||||
""" returns True if associated RAW image and the RAW image is selected in Photos
|
||||
via "Use RAW as Original "
|
||||
otherwise returns False """
|
||||
return self._info["raw_is_original"]
|
||||
|
||||
@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,
|
||||
dest,
|
||||
*filename,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
overwrite=False,
|
||||
increment=True,
|
||||
sidecar_json=False,
|
||||
sidecar_xmp=False,
|
||||
use_photos_export=False,
|
||||
timeout=120,
|
||||
exiftool=False,
|
||||
no_xattr=False,
|
||||
template_str,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
):
|
||||
""" export photo
|
||||
dest: must be valid destination path (or exception raised)
|
||||
filename: (optional): name of exported picture; if not provided, will use current filename
|
||||
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
|
||||
For example, if photo is .CR2 file, edited image may be .jpeg.
|
||||
If you provide an extension different than what the actual file is,
|
||||
export will print a warning but will happily export the photo using the
|
||||
incorrect file extension. e.g. to get the extension of the edited photo,
|
||||
reference PhotoInfo.path_edited
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||
(or raise exception if no edited version)
|
||||
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
|
||||
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
||||
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
||||
if overwrite=False and increment=False, export will fail if destination file already exists
|
||||
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json
|
||||
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
|
||||
sidecar filename will be dest/filename.xmp
|
||||
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
||||
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
||||
no_xattr: (boolean, default = False); if True, exports file without preserving extended attributes
|
||||
returns list of full paths to the exported files """
|
||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||
|
||||
# list of all files exported during this call to export
|
||||
exported_files = []
|
||||
|
||||
# check edited and raise exception trying to export edited version of
|
||||
# photo that hasn't been edited
|
||||
if edited and not self.hasadjustments:
|
||||
raise ValueError(
|
||||
"Photo does not have adjustments, cannot export edited version"
|
||||
)
|
||||
|
||||
# check arguments and get destination path and filename (if provided)
|
||||
if filename and len(filename) > 2:
|
||||
raise TypeError(
|
||||
"Too many positional arguments. Should be at most two: destination, filename."
|
||||
)
|
||||
else:
|
||||
# verify destination is a valid path
|
||||
if dest is None:
|
||||
raise ValueError("Destination must not be None")
|
||||
elif not os.path.isdir(dest):
|
||||
raise FileNotFoundError("Invalid path passed to export")
|
||||
|
||||
if filename and len(filename) == 1:
|
||||
# if filename passed, use it
|
||||
fname = filename[0]
|
||||
else:
|
||||
# no filename provided so use the default
|
||||
# if edited file requested, use filename but add _edited
|
||||
# need to use file extension from edited file as Photos saves a jpeg once edited
|
||||
if edited and not use_photos_export:
|
||||
# verify we have a valid path_edited and use that to get filename
|
||||
if not self.path_edited:
|
||||
raise FileNotFoundError(
|
||||
"edited=True but path_edited is none; hasadjustments: "
|
||||
f" {self.hasadjustments}"
|
||||
)
|
||||
edited_name = pathlib.Path(self.path_edited).name
|
||||
edited_suffix = pathlib.Path(edited_name).suffix
|
||||
fname = pathlib.Path(self.filename).stem + "_edited" + edited_suffix
|
||||
else:
|
||||
fname = self.filename
|
||||
|
||||
# check destination path
|
||||
dest = pathlib.Path(dest)
|
||||
fname = pathlib.Path(fname)
|
||||
dest = dest / fname
|
||||
|
||||
# check extension of destination
|
||||
if edited and self.path_edited is not None:
|
||||
# use suffix from edited file
|
||||
actual_suffix = pathlib.Path(self.path_edited).suffix
|
||||
elif edited:
|
||||
# use .jpeg as that's probably correct
|
||||
# if edited and path_edited is None, will raise FileNotFoundError below
|
||||
# unless use_photos_export is True
|
||||
actual_suffix = ".jpeg"
|
||||
else:
|
||||
# use suffix from the non-edited file
|
||||
actual_suffix = pathlib.Path(self.filename).suffix
|
||||
|
||||
# warn if suffixes don't match but ignore .JPG / .jpeg as
|
||||
# Photo's often converts .JPG to .jpeg
|
||||
suffixes = sorted([x.lower() for x in [dest.suffix, actual_suffix]])
|
||||
if dest.suffix != actual_suffix and suffixes != [".jpeg", ".jpg"]:
|
||||
logging.warning(
|
||||
f"Invalid destination suffix: {dest.suffix}, should be {actual_suffix}"
|
||||
)
|
||||
|
||||
# check to see if file exists and if so, add (1), (2), etc until we find one that works
|
||||
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
|
||||
# e.g. exporting sidecar for file1.png and file1.jpeg
|
||||
# if file1.png exists and exporting file1.jpeg,
|
||||
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
|
||||
if increment and not overwrite:
|
||||
count = 1
|
||||
glob_str = str(dest.parent / f"{dest.stem}*")
|
||||
dest_files = glob.glob(glob_str)
|
||||
dest_files = [pathlib.Path(f).stem for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
while dest_new in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
|
||||
# if overwrite==False and #increment==False, export should fail if file exists
|
||||
if dest.exists() and not overwrite and not increment:
|
||||
raise FileExistsError(
|
||||
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
|
||||
)
|
||||
|
||||
if not use_photos_export:
|
||||
# find the source file on disk and export
|
||||
# get path to source file and verify it's not None and is valid file
|
||||
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
|
||||
if edited:
|
||||
if self.path_edited is not None:
|
||||
src = self.path_edited
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Cannot export edited photo if path_edited is None"
|
||||
)
|
||||
else:
|
||||
if self.ismissing:
|
||||
logging.warning(
|
||||
f"Attempting to export photo with ismissing=True: path = {self.path}"
|
||||
)
|
||||
|
||||
if self.path is not None:
|
||||
src = self.path
|
||||
else:
|
||||
raise FileNotFoundError("Cannot export photo if path is None")
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError(f"{src} does not appear to exist")
|
||||
|
||||
logging.debug(
|
||||
f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}"
|
||||
)
|
||||
|
||||
# copy the file, _copy_file uses ditto to preserve Mac extended attributes
|
||||
_copy_file(src, dest, norsrc=no_xattr)
|
||||
exported_files.append(str(dest))
|
||||
|
||||
# copy live photo associated .mov if requested
|
||||
if live_photo and self.live_photo:
|
||||
live_name = dest.parent / f"{dest.stem}.mov"
|
||||
src_live = self.path_live_photo
|
||||
|
||||
if src_live is not None:
|
||||
logging.debug(
|
||||
f"Exporting live photo video of {filename} as {live_name.name}"
|
||||
)
|
||||
_copy_file(src_live, str(live_name), norsrc=no_xattr)
|
||||
exported_files.append(str(live_name))
|
||||
else:
|
||||
logging.warning(f"Skipping missing live movie for {filename}")
|
||||
else:
|
||||
# use_photo_export
|
||||
exported = None
|
||||
# export live_photo .mov file?
|
||||
live_photo = True if live_photo and self.live_photo else False
|
||||
if edited:
|
||||
# exported edited version and not original
|
||||
if filename:
|
||||
# use filename stem provided
|
||||
filestem = dest.stem
|
||||
else:
|
||||
# didn't get passed a filename, add _edited
|
||||
filestem = f"{dest.stem}_edited"
|
||||
dest = dest.parent / f"{filestem}.jpeg"
|
||||
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
original=False,
|
||||
edited=True,
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
)
|
||||
else:
|
||||
# export original version and not edited
|
||||
filestem = dest.stem
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
original=True,
|
||||
edited=False,
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
)
|
||||
|
||||
if exported is not None:
|
||||
exported_files.extend(exported)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Error exporting photo {self.uuid} to {dest} with use_photos_export"
|
||||
)
|
||||
|
||||
if sidecar_json:
|
||||
logging.debug("writing exiftool_json_sidecar")
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.json")
|
||||
sidecar_str = self._exiftool_json_sidecar()
|
||||
try:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
|
||||
if sidecar_xmp:
|
||||
logging.debug("writing xmp_sidecar")
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.xmp")
|
||||
sidecar_str = self._xmp_sidecar()
|
||||
try:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
|
||||
# if exiftool, write the metadata
|
||||
if exiftool and exported_files:
|
||||
for exported_file in exported_files:
|
||||
self._write_exif_data(exported_file)
|
||||
|
||||
return exported_files
|
||||
|
||||
def _write_exif_data(self, filepath):
|
||||
""" write exif data to image file at filepath
|
||||
filepath: full path to the image file """
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Could not find file {filepath}")
|
||||
exiftool = ExifTool(filepath)
|
||||
exif_info = json.loads(self._exiftool_json_sidecar())[0]
|
||||
for exiftag, val in exif_info.items():
|
||||
if type(val) == list:
|
||||
# more than one, set first value the add additional values
|
||||
exiftool.setvalue(exiftag, val.pop(0))
|
||||
if val:
|
||||
# add any remaining items
|
||||
exiftool.addvalues(exiftag, *val)
|
||||
else:
|
||||
exiftool.setvalue(exiftag, val)
|
||||
|
||||
def _exiftool_json_sidecar(self):
|
||||
""" return json string of EXIF details in exiftool sidecar format
|
||||
Does not include all the EXIF fields as those are likely already in the image
|
||||
Exports the following:
|
||||
FileName
|
||||
ImageDescription
|
||||
Description
|
||||
Title
|
||||
TagsList
|
||||
Keywords
|
||||
Subject
|
||||
PersonInImage
|
||||
GPSLatitude, GPSLongitude
|
||||
GPSPosition
|
||||
GPSLatitudeRef, GPSLongitudeRef
|
||||
DateTimeOriginal
|
||||
OffsetTimeOriginal
|
||||
ModifyDate """
|
||||
|
||||
exif = {}
|
||||
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
|
||||
|
||||
if self.description:
|
||||
exif["EXIF:ImageDescription"] = self.description
|
||||
exif["XMP:Description"] = self.description
|
||||
|
||||
if self.title:
|
||||
exif["XMP:Title"] = self.title
|
||||
|
||||
if self.keywords:
|
||||
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords)
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
exif["XMP:Subject"] = list(self.keywords)
|
||||
|
||||
if self.persons:
|
||||
exif["XMP:PersonInImage"] = self.persons
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
if "XMP:Subject" in exif:
|
||||
exif["XMP:Subject"].extend(self.persons)
|
||||
else:
|
||||
exif["XMP:Subject"] = self.persons
|
||||
|
||||
# if self.favorite():
|
||||
# exif["Rating"] = 5
|
||||
|
||||
(lat, lon) = self.location
|
||||
if lat is not None and lon is not None:
|
||||
lat_str, lon_str = dd_to_dms_str(lat, lon)
|
||||
exif["EXIF:GPSLatitude"] = lat_str
|
||||
exif["EXIF:GPSLongitude"] = lon_str
|
||||
exif["Composite:GPSPosition"] = f"{lat_str}, {lon_str}"
|
||||
lat_ref = "North" if lat >= 0 else "South"
|
||||
lon_ref = "East" if lon >= 0 else "West"
|
||||
exif["EXIF:GPSLatitudeRef"] = lat_ref
|
||||
exif["EXIF:GPSLongitudeRef"] = lon_ref
|
||||
|
||||
# process date/time and timezone offset
|
||||
date = self.date
|
||||
# exiftool expects format to "2015:01:18 12:00:00"
|
||||
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
||||
offsettime = date.strftime("%z")
|
||||
# find timezone offset in format "-04:00"
|
||||
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
|
||||
offset = offset[0] # findall returns list of tuples
|
||||
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
|
||||
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
||||
exif["EXIF:OffsetTimeOriginal"] = offsettime
|
||||
|
||||
if self.date_modified is not None:
|
||||
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
|
||||
|
||||
json_str = json.dumps([exif])
|
||||
return json_str
|
||||
|
||||
def _xmp_sidecar(self):
|
||||
""" returns string for XMP sidecar """
|
||||
# TODO: add additional fields to XMP file?
|
||||
|
||||
xmp_template = Template(
|
||||
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)
|
||||
Args:
|
||||
template_str: a template string with fields to render
|
||||
none_str: a str to use if template field renders to None, default is "_".
|
||||
path_sep: a single character str to use as path separator when joining
|
||||
fields like folder_album; if not provided, defaults to os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
"""
|
||||
template = PhotoTemplate(self)
|
||||
return template.render(
|
||||
template_str,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
)
|
||||
xmp_str = xmp_template.render(photo=self)
|
||||
# remove extra lines that mako inserts from template
|
||||
xmp_str = "\n".join(
|
||||
[line for line in xmp_str.split("\n") if line.strip() != ""]
|
||||
)
|
||||
return xmp_str
|
||||
|
||||
def _write_sidecar(self, filename, sidecar_str):
|
||||
""" write sidecar_str to filename
|
||||
used for exporting sidecar info """
|
||||
if not filename and not sidecar_str:
|
||||
raise (
|
||||
ValueError(
|
||||
f"filename {filename} and sidecar_str {sidecar_str} must not be None"
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: catch exception?
|
||||
f = open(filename, "w")
|
||||
f.write(sidecar_str)
|
||||
f.close()
|
||||
|
||||
@property
|
||||
def _longitude(self):
|
||||
@@ -888,6 +751,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})"
|
||||
|
||||
@@ -898,6 +792,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,
|
||||
@@ -935,6 +831,20 @@ class PhotoInfo:
|
||||
"hdr": self.hdr,
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
"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)
|
||||
|
||||
@@ -944,6 +854,10 @@ class PhotoInfo:
|
||||
date_modified_iso = (
|
||||
self.date_modified.isoformat() if self.date_modified else None
|
||||
)
|
||||
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,
|
||||
@@ -953,7 +867,10 @@ class PhotoInfo:
|
||||
"description": self.description,
|
||||
"title": self.title,
|
||||
"keywords": self.keywords,
|
||||
"labels": self.labels,
|
||||
"keywords": self.keywords,
|
||||
"albums": self.albums,
|
||||
"folders": folders,
|
||||
"persons": self.persons,
|
||||
"path": self.path,
|
||||
"ismissing": self.ismissing,
|
||||
@@ -981,15 +898,35 @@ class PhotoInfo:
|
||||
"hdr": self.hdr,
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
"has_raw": self.has_raw,
|
||||
"uti_raw": self.uti_raw,
|
||||
"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)
|
||||
7
osxphotos/photosdb/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
PhotosDB class
|
||||
Processes a Photos.app library database to extract information about photos
|
||||
"""
|
||||
|
||||
from .photosdb import PhotosDB
|
||||
from .photosdb_utils import get_db_version, get_db_model_version, get_model_version
|
||||
60
osxphotos/photosdb/_photosdb_process_exif.py
Normal file
@@ -0,0 +1,60 @@
|
||||
""" PhotosDB method for processing exif info
|
||||
Do not import this module directly """
|
||||
|
||||
import logging
|
||||
|
||||
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
|
||||
from ..utils import _db_is_locked, _debug, _open_sql_file
|
||||
from .photosdb_utils import get_db_version
|
||||
|
||||
def _process_exifinfo(self):
|
||||
""" load the exif data from the database
|
||||
this is a PhotosDB method that should be imported in
|
||||
the PhotosDB class definition in photosdb.py
|
||||
"""
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
_process_exifinfo_4(self)
|
||||
else:
|
||||
_process_exifinfo_5(self)
|
||||
|
||||
|
||||
# The following methods do not get imported into PhotosDB
|
||||
# but will get called by _process_exifinfo
|
||||
|
||||
|
||||
def _process_exifinfo_4(photosdb):
|
||||
""" process exif info for Photos <= 4
|
||||
photosdb: PhotosDB instance """
|
||||
photosdb._db_exifinfo_uuid = {}
|
||||
raise NotImplementedError(f"search info not implemented for this database version")
|
||||
|
||||
|
||||
def _process_exifinfo_5(photosdb):
|
||||
""" process exif info for Photos >= 5
|
||||
photosdb: PhotosDB instance """
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = conn.execute(
|
||||
f"""
|
||||
SELECT {asset_table}.ZUUID, ZEXTENDEDATTRIBUTES.*
|
||||
FROM {asset_table}
|
||||
JOIN ZEXTENDEDATTRIBUTES
|
||||
ON ZEXTENDEDATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
"""
|
||||
)
|
||||
|
||||
photosdb._db_exifinfo_uuid = {}
|
||||
cols = [c[0] for c in result.description]
|
||||
for row in result.fetchall():
|
||||
record = dict(zip(cols, row))
|
||||
uuid = record["ZUUID"]
|
||||
if uuid in photosdb._db_exifinfo_uuid:
|
||||
logging.warning(f"duplicate exifinfo record found for uuid {uuid}")
|
||||
photosdb._db_exifinfo_uuid[uuid] = record
|
||||
|
||||
conn.close()
|
||||
331
osxphotos/photosdb/_photosdb_process_faceinfo.py
Normal file
@@ -0,0 +1,331 @@
|
||||
""" Methods for PhotosDB to add Photos face info
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
|
||||
from ..utils import _open_sql_file, normalize_unicode
|
||||
from .photosdb_utils import get_db_version
|
||||
|
||||
|
||||
"""
|
||||
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_faceinfo: process photo face info
|
||||
|
||||
The following data structures are added to PhotosDB
|
||||
self._db_faceinfo_pk: {pk: {faceinfo}}
|
||||
self._db_faceinfo_uuid: {photo uuid: [face pk]}
|
||||
self._db_faceinfo_person: {person_pk: [face_pk]}
|
||||
"""
|
||||
|
||||
|
||||
def _process_faceinfo(self):
|
||||
""" Process face information
|
||||
"""
|
||||
|
||||
self._db_faceinfo_pk = {}
|
||||
self._db_faceinfo_uuid = {}
|
||||
self._db_faceinfo_person = {}
|
||||
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
_process_faceinfo_4(self)
|
||||
else:
|
||||
_process_faceinfo_5(self)
|
||||
|
||||
|
||||
def _process_faceinfo_4(photosdb):
|
||||
""" Process face information for Photos 4 databases
|
||||
|
||||
Args:
|
||||
photosdb: an OSXPhotosDB instance
|
||||
"""
|
||||
db = photosdb._tmp_db
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
RKFace.modelId,
|
||||
RKVersion.uuid,
|
||||
RKFace.uuid,
|
||||
RKPerson.name,
|
||||
RKFace.isInTrash,
|
||||
RKFace.personId,
|
||||
RKFace.imageModelId,
|
||||
RKFace.sourceWidth,
|
||||
RKFace.sourceHeight,
|
||||
RKFace.centerX,
|
||||
RKFace.centerY,
|
||||
RKFace.size,
|
||||
RKFace.leftEyeX,
|
||||
RKFace.leftEyeY,
|
||||
RKFace.rightEyeX,
|
||||
RKFace.rightEyeY,
|
||||
RKFace.mouthX,
|
||||
RKFace.mouthY,
|
||||
RKFace.hidden,
|
||||
RKFace.manual,
|
||||
RKFace.hasSmile,
|
||||
RKFace.isLeftEyeClosed,
|
||||
RKFace.isRightEyeClosed,
|
||||
RKFace.poseRoll,
|
||||
RKFace.poseYaw,
|
||||
RKFace.posePitch,
|
||||
RKFace.faceType,
|
||||
RKFace.qualityMeasure
|
||||
FROM
|
||||
RKFace
|
||||
JOIN RKPerson on RKPerson.modelId = RKFace.personId
|
||||
JOIN RKVersion on RKVersion.modelId = RKFace.imageModelId
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 RKFace.modelId,
|
||||
# 1 RKVersion.uuid,
|
||||
# 2 RKFace.uuid,
|
||||
# 3 RKPerson.name,
|
||||
# 4 RKFace.isInTrash,
|
||||
# 5 RKFace.personId,
|
||||
# 6 RKFace.imageModelId,
|
||||
# 7 RKFace.sourceWidth,
|
||||
# 8 RKFace.sourceHeight,
|
||||
# 9 RKFace.centerX,
|
||||
# 10 RKFace.centerY,
|
||||
# 11 RKFace.size,
|
||||
# 12 RKFace.leftEyeX,
|
||||
# 13 RKFace.leftEyeY,
|
||||
# 14 RKFace.rightEyeX,
|
||||
# 15 RKFace.rightEyeY,
|
||||
# 16 RKFace.mouthX,
|
||||
# 17 RKFace.mouthY,
|
||||
# 18 RKFace.hidden,
|
||||
# 19 RKFace.manual,
|
||||
# 20 RKFace.hasSmile,
|
||||
# 21 RKFace.isLeftEyeClosed,
|
||||
# 22 RKFace.isRightEyeClosed,
|
||||
# 23 RKFace.poseRoll,
|
||||
# 24 RKFace.poseYaw,
|
||||
# 25 RKFace.posePitch,
|
||||
# 26 RKFace.faceType,
|
||||
# 27 RKFace.qualityMeasure
|
||||
|
||||
for row in result:
|
||||
modelid = row[0]
|
||||
asset_uuid = row[1]
|
||||
person_id = row[5]
|
||||
face = {}
|
||||
face["pk"] = modelid
|
||||
face["asset_uuid"] = asset_uuid
|
||||
face["uuid"] = row[2]
|
||||
face["person"] = person_id
|
||||
face["fullname"] = normalize_unicode(row[3])
|
||||
face["sourcewidth"] = row[7]
|
||||
face["sourceheight"] = row[8]
|
||||
face["centerx"] = row[9]
|
||||
face["centery"] = row[10]
|
||||
face["size"] = row[11]
|
||||
face["lefteyex"] = row[12]
|
||||
face["lefteyey"] = row[13]
|
||||
face["righteyex"] = row[14]
|
||||
face["righteyey"] = row[15]
|
||||
face["mouthx"] = row[16]
|
||||
face["mouthy"] = row[17]
|
||||
face["hidden"] = row[18]
|
||||
face["manual"] = row[19]
|
||||
face["has_smile"] = row[20]
|
||||
face["left_eye_closed"] = row[21]
|
||||
face["right_eye_closed"] = row[22]
|
||||
face["roll"] = row[23]
|
||||
face["yaw"] = row[24]
|
||||
face["pitch"] = row[25]
|
||||
face["facetype"] = row[26]
|
||||
face["quality"] = row[27]
|
||||
|
||||
# Photos 5 only
|
||||
face["agetype"] = None
|
||||
face["baldtype"] = None
|
||||
face["eyemakeuptype"] = None
|
||||
face["eyestate"] = None
|
||||
face["facialhairtype"] = None
|
||||
face["gendertype"] = None
|
||||
face["glassestype"] = None
|
||||
face["haircolortype"] = None
|
||||
face["intrash"] = None
|
||||
face["lipmakeuptype"] = None
|
||||
face["smiletype"] = None
|
||||
|
||||
photosdb._db_faceinfo_pk[modelid] = face
|
||||
|
||||
try:
|
||||
photosdb._db_faceinfo_uuid[asset_uuid].append(modelid)
|
||||
except KeyError:
|
||||
photosdb._db_faceinfo_uuid[asset_uuid] = [modelid]
|
||||
|
||||
try:
|
||||
photosdb._db_faceinfo_person[person_id].append(modelid)
|
||||
except KeyError:
|
||||
photosdb._db_faceinfo_person[person_id] = [modelid]
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def _process_faceinfo_5(photosdb):
|
||||
""" Process face information for Photos 5 databases
|
||||
|
||||
Args:
|
||||
photosdb: an OSXPhotosDB instance
|
||||
"""
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = cursor.execute(
|
||||
f"""
|
||||
SELECT
|
||||
ZDETECTEDFACE.Z_PK,
|
||||
{asset_table}.ZUUID,
|
||||
ZDETECTEDFACE.ZUUID,
|
||||
ZDETECTEDFACE.ZPERSON,
|
||||
ZPERSON.ZFULLNAME,
|
||||
ZDETECTEDFACE.ZAGETYPE,
|
||||
ZDETECTEDFACE.ZBALDTYPE,
|
||||
ZDETECTEDFACE.ZEYEMAKEUPTYPE,
|
||||
ZDETECTEDFACE.ZEYESSTATE,
|
||||
ZDETECTEDFACE.ZFACIALHAIRTYPE,
|
||||
ZDETECTEDFACE.ZGENDERTYPE,
|
||||
ZDETECTEDFACE.ZGLASSESTYPE,
|
||||
ZDETECTEDFACE.ZHAIRCOLORTYPE,
|
||||
ZDETECTEDFACE.ZHASSMILE,
|
||||
ZDETECTEDFACE.ZHIDDEN,
|
||||
ZDETECTEDFACE.ZISINTRASH,
|
||||
ZDETECTEDFACE.ZISLEFTEYECLOSED,
|
||||
ZDETECTEDFACE.ZISRIGHTEYECLOSED,
|
||||
ZDETECTEDFACE.ZLIPMAKEUPTYPE,
|
||||
ZDETECTEDFACE.ZMANUAL,
|
||||
ZDETECTEDFACE.ZQUALITYMEASURE,
|
||||
ZDETECTEDFACE.ZSMILETYPE,
|
||||
ZDETECTEDFACE.ZSOURCEHEIGHT,
|
||||
ZDETECTEDFACE.ZSOURCEWIDTH,
|
||||
ZDETECTEDFACE.ZBLURSCORE,
|
||||
ZDETECTEDFACE.ZCENTERX,
|
||||
ZDETECTEDFACE.ZCENTERY,
|
||||
ZDETECTEDFACE.ZLEFTEYEX,
|
||||
ZDETECTEDFACE.ZLEFTEYEY,
|
||||
ZDETECTEDFACE.ZMOUTHX,
|
||||
ZDETECTEDFACE.ZMOUTHY,
|
||||
ZDETECTEDFACE.ZPOSEYAW,
|
||||
ZDETECTEDFACE.ZQUALITY,
|
||||
ZDETECTEDFACE.ZRIGHTEYEX,
|
||||
ZDETECTEDFACE.ZRIGHTEYEY,
|
||||
ZDETECTEDFACE.ZROLL,
|
||||
ZDETECTEDFACE.ZSIZE,
|
||||
ZDETECTEDFACE.ZYAW,
|
||||
ZDETECTEDFACE.ZMASTERIDENTIFIER
|
||||
FROM ZDETECTEDFACE
|
||||
JOIN {asset_table} ON {asset_table}.Z_PK = ZDETECTEDFACE.ZASSET
|
||||
JOIN ZPERSON ON ZPERSON.Z_PK = ZDETECTEDFACE.ZPERSON;
|
||||
"""
|
||||
)
|
||||
|
||||
# 0 ZDETECTEDFACE.Z_PK
|
||||
# 1 ZGENERICASSET.ZUUID,
|
||||
# 2 ZDETECTEDFACE.ZUUID,
|
||||
# 3 ZDETECTEDFACE.ZPERSON,
|
||||
# 4 ZPERSON.ZFULLNAME,
|
||||
# 5 ZDETECTEDFACE.ZAGETYPE,
|
||||
# 6 ZDETECTEDFACE.ZBALDTYPE,
|
||||
# 7 ZDETECTEDFACE.ZEYEMAKEUPTYPE,
|
||||
# 8 ZDETECTEDFACE.ZEYESSTATE,
|
||||
# 9 ZDETECTEDFACE.ZFACIALHAIRTYPE,
|
||||
# 10 ZDETECTEDFACE.ZGENDERTYPE,
|
||||
# 11 ZDETECTEDFACE.ZGLASSESTYPE,
|
||||
# 12 ZDETECTEDFACE.ZHAIRCOLORTYPE,
|
||||
# 13 ZDETECTEDFACE.ZHASSMILE,
|
||||
# 14 ZDETECTEDFACE.ZHIDDEN,
|
||||
# 15 ZDETECTEDFACE.ZISINTRASH,
|
||||
# 16 ZDETECTEDFACE.ZISLEFTEYECLOSED,
|
||||
# 17 ZDETECTEDFACE.ZISRIGHTEYECLOSED,
|
||||
# 18 ZDETECTEDFACE.ZLIPMAKEUPTYPE,
|
||||
# 19 ZDETECTEDFACE.ZMANUAL,
|
||||
# 20 ZDETECTEDFACE.ZQUALITYMEASURE,
|
||||
# 21 ZDETECTEDFACE.ZSMILETYPE,
|
||||
# 22 ZDETECTEDFACE.ZSOURCEHEIGHT,
|
||||
# 23 ZDETECTEDFACE.ZSOURCEWIDTH,
|
||||
# 24 ZDETECTEDFACE.ZBLURSCORE,
|
||||
# 25 ZDETECTEDFACE.ZCENTERX,
|
||||
# 26 ZDETECTEDFACE.ZCENTERY,
|
||||
# 27 ZDETECTEDFACE.ZLEFTEYEX,
|
||||
# 28 ZDETECTEDFACE.ZLEFTEYEY,
|
||||
# 29 ZDETECTEDFACE.ZMOUTHX,
|
||||
# 30 ZDETECTEDFACE.ZMOUTHY,
|
||||
# 31 ZDETECTEDFACE.ZPOSEYAW,
|
||||
# 32 ZDETECTEDFACE.ZQUALITY,
|
||||
# 33 ZDETECTEDFACE.ZRIGHTEYEX,
|
||||
# 34 ZDETECTEDFACE.ZRIGHTEYEY,
|
||||
# 35 ZDETECTEDFACE.ZROLL,
|
||||
# 36 ZDETECTEDFACE.ZSIZE,
|
||||
# 37 ZDETECTEDFACE.ZYAW,
|
||||
# 38 ZDETECTEDFACE.ZMASTERIDENTIFIER
|
||||
|
||||
for row in result:
|
||||
pk = row[0]
|
||||
asset_uuid = row[1]
|
||||
person_pk = row[3]
|
||||
face = {}
|
||||
face["pk"] = pk
|
||||
face["asset_uuid"] = asset_uuid
|
||||
face["uuid"] = row[2]
|
||||
face["person"] = person_pk
|
||||
face["fullname"] = normalize_unicode(row[4])
|
||||
face["agetype"] = row[5]
|
||||
face["baldtype"] = row[6]
|
||||
face["eyemakeuptype"] = row[7]
|
||||
face["eyestate"] = row[8]
|
||||
face["facialhairtype"] = row[9]
|
||||
face["gendertype"] = row[10]
|
||||
face["glassestype"] = row[11]
|
||||
face["haircolortype"] = row[12]
|
||||
face["has_smile"] = row[13]
|
||||
face["hidden"] = row[14]
|
||||
face["intrash"] = row[15]
|
||||
face["left_eye_closed"] = row[16]
|
||||
face["right_eye_closed"] = row[17]
|
||||
face["lipmakeuptype"] = row[18]
|
||||
face["manual"] = row[19]
|
||||
face["smiletype"] = row[21]
|
||||
face["sourceheight"] = row[22]
|
||||
face["sourcewidth"] = row[23]
|
||||
face["facetype"] = None # Photos 4 only
|
||||
face["centerx"] = row[25]
|
||||
face["centery"] = row[26]
|
||||
face["lefteyex"] = row[27]
|
||||
face["lefteyey"] = row[28]
|
||||
face["mouthx"] = row[29]
|
||||
face["mouthy"] = row[30]
|
||||
face["quality"] = row[32]
|
||||
face["righteyex"] = row[33]
|
||||
face["righteyey"] = row[34]
|
||||
face["roll"] = row[35]
|
||||
face["size"] = row[36]
|
||||
face["yaw"] = row[37]
|
||||
face["pitch"] = 0.0 # not defined in Photos 5
|
||||
|
||||
photosdb._db_faceinfo_pk[pk] = face
|
||||
|
||||
try:
|
||||
photosdb._db_faceinfo_uuid[asset_uuid].append(pk)
|
||||
except KeyError:
|
||||
photosdb._db_faceinfo_uuid[asset_uuid] = [pk]
|
||||
|
||||
try:
|
||||
photosdb._db_faceinfo_person[person_pk].append(pk)
|
||||
except KeyError:
|
||||
photosdb._db_faceinfo_person[person_pk] = [pk]
|
||||
|
||||
conn.close()
|
||||
150
osxphotos/photosdb/_photosdb_process_scoreinfo.py
Normal file
@@ -0,0 +1,150 @@
|
||||
""" Methods for PhotosDB to add Photos 5 photo score info
|
||||
ref: https://simonwillison.net/2020/May/21/dogsheep-photos/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
|
||||
from ..utils import _open_sql_file
|
||||
from .photosdb_utils import get_db_version
|
||||
|
||||
"""
|
||||
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
|
||||
|
||||
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = cursor.execute(
|
||||
f"""
|
||||
SELECT
|
||||
{asset_table}.ZUUID,
|
||||
{asset_table}.ZOVERALLAESTHETICSCORE,
|
||||
{asset_table}.ZCURATIONSCORE,
|
||||
{asset_table}.ZPROMOTIONSCORE,
|
||||
{asset_table}.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 {asset_table}
|
||||
JOIN ZCOMPUTEDASSETATTRIBUTES ON ZCOMPUTEDASSETATTRIBUTES.ZASSET = {asset_table}.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
|
||||
|
||||
conn.close()
|
||||
210
osxphotos/photosdb/_photosdb_process_searchinfo.py
Normal file
@@ -0,0 +1,210 @@
|
||||
""" Methods for PhotosDB to add Photos 5 search info such as machine learning labels
|
||||
Kudos to Simon Willison who figured out how to extract this data from psi.sql
|
||||
ref: https://github.com/dogsheep/photos-to-sqlite/issues/16
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
import logging
|
||||
import pathlib
|
||||
import uuid as uuidlib
|
||||
from pprint import pformat
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
|
||||
from ..utils import _db_is_locked, _debug, _open_sql_file, normalize_unicode
|
||||
|
||||
"""
|
||||
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_searchinfo: process search terms from psi.sqlite
|
||||
|
||||
The following properties are added to PhotosDB
|
||||
labels: list of all labels in the library
|
||||
labels_normalized: list of all labels normalized in the library
|
||||
labels_as_dict: dict of {label: count of photos} in reverse sorted order (most photos first)
|
||||
labels_normalized_as_dict: dict of {normalized label: count of photos} in reverse sorted order (most photos first)
|
||||
|
||||
The following data structures are added to PhotosDB
|
||||
self._db_searchinfo_categories
|
||||
self._db_searchinfo_uuid
|
||||
self._db_searchinfo_labels
|
||||
self._db_searchinfo_labels_normalized
|
||||
|
||||
These methods only work on Photos 5 databases. Will print warning on earlier library versions.
|
||||
"""
|
||||
|
||||
|
||||
def _process_searchinfo(self):
|
||||
""" load machine learning/search term label info from a Photos library
|
||||
db_connection: a connection to the SQLite database file containing the
|
||||
search terms. In Photos 5, this is called psi.sqlite
|
||||
Note: Only works on Photos version == 5.0 """
|
||||
|
||||
# _db_searchinfo_uuid is dict in form {uuid : [list of associated search info records]
|
||||
self._db_searchinfo_uuid = _db_searchinfo_uuid = {}
|
||||
|
||||
# _db_searchinfo_categories is dict in form {search info category id: list normalized strings for the category
|
||||
# right now, this is mostly for debugging to easily see which search terms are in the library
|
||||
self._db_searchinfo_categories = _db_searchinfo_categories = {}
|
||||
|
||||
# _db_searchinfo_labels is dict in form {normalized label: [list of photo uuids]}
|
||||
# this serves as a reverse index from label to photos containing the label
|
||||
# _db_searchinfo_labels_normalized is the same but with normalized (lower case) version of the label
|
||||
self._db_searchinfo_labels = _db_searchinfo_labels = {}
|
||||
self._db_searchinfo_labels_normalized = _db_searchinfo_labels_normalized = {}
|
||||
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
raise NotImplementedError(
|
||||
f"search info not implemented for this database version"
|
||||
)
|
||||
|
||||
search_db_path = pathlib.Path(self._dbfile).parent / "search" / "psi.sqlite"
|
||||
if not search_db_path.exists():
|
||||
logging.warning(f"could not find search db: {search_db_path}")
|
||||
return None
|
||||
|
||||
if _db_is_locked(search_db_path):
|
||||
search_db = self._copy_db_file(search_db_path)
|
||||
else:
|
||||
search_db = search_db_path
|
||||
|
||||
(conn, c) = _open_sql_file(search_db)
|
||||
|
||||
result = c.execute(
|
||||
"""
|
||||
select
|
||||
ga.rowid,
|
||||
assets.uuid_0,
|
||||
assets.uuid_1,
|
||||
groups.rowid as groupid,
|
||||
groups.category,
|
||||
groups.owning_groupid,
|
||||
groups.content_string,
|
||||
groups.normalized_string,
|
||||
groups.lookup_identifier
|
||||
from
|
||||
ga
|
||||
join groups on groups.rowid = ga.groupid
|
||||
join assets on ga.assetid = assets.rowid
|
||||
order by
|
||||
ga.rowid
|
||||
"""
|
||||
)
|
||||
|
||||
# 0: ga.rowid,
|
||||
# 1: assets.uuid_0,
|
||||
# 2: assets.uuid_1,
|
||||
# 3: groups.rowid as groupid,
|
||||
# 4: groups.category,
|
||||
# 5: groups.owning_groupid,
|
||||
# 6: groups.content_string,
|
||||
# 7: groups.normalized_string,
|
||||
# 8: groups.lookup_identifier
|
||||
|
||||
for row in c:
|
||||
uuid = ints_to_uuid(row[1], row[2])
|
||||
# strings have null character appended, so strip it
|
||||
record = {}
|
||||
record["uuid"] = uuid
|
||||
record["rowid"] = row[0]
|
||||
record["uuid_0"] = row[1]
|
||||
record["uuid_1"] = row[2]
|
||||
record["groupid"] = row[3]
|
||||
record["category"] = row[4]
|
||||
record["owning_groupid"] = row[5]
|
||||
record["content_string"] = normalize_unicode(row[6].replace("\x00", ""))
|
||||
record["normalized_string"] = normalize_unicode(row[7].replace("\x00", ""))
|
||||
record["lookup_identifier"] = row[8]
|
||||
|
||||
try:
|
||||
_db_searchinfo_uuid[uuid].append(record)
|
||||
except KeyError:
|
||||
_db_searchinfo_uuid[uuid] = [record]
|
||||
|
||||
category = record["category"]
|
||||
try:
|
||||
_db_searchinfo_categories[category].append(record["normalized_string"])
|
||||
except KeyError:
|
||||
_db_searchinfo_categories[category] = [record["normalized_string"]]
|
||||
|
||||
if category == SEARCH_CATEGORY_LABEL:
|
||||
label = record["content_string"]
|
||||
label_norm = record["normalized_string"]
|
||||
try:
|
||||
_db_searchinfo_labels[label].append(uuid)
|
||||
_db_searchinfo_labels_normalized[label_norm].append(uuid)
|
||||
except KeyError:
|
||||
_db_searchinfo_labels[label] = [uuid]
|
||||
_db_searchinfo_labels_normalized[label_norm] = [uuid]
|
||||
|
||||
if _debug():
|
||||
logging.debug(
|
||||
"_db_searchinfo_categories: \n" + pformat(self._db_searchinfo_categories)
|
||||
)
|
||||
logging.debug("_db_searchinfo_uuid: \n" + pformat(self._db_searchinfo_uuid))
|
||||
logging.debug("_db_searchinfo_labels: \n" + pformat(self._db_searchinfo_labels))
|
||||
logging.debug(
|
||||
"_db_searchinfo_labels_normalized: \n"
|
||||
+ pformat(self._db_searchinfo_labels_normalized)
|
||||
)
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" return list of all search info labels found in the library """
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.warning(f"SearchInfo not implemented for this library version")
|
||||
return []
|
||||
|
||||
return list(self._db_searchinfo_labels.keys())
|
||||
|
||||
|
||||
@property
|
||||
def labels_normalized(self):
|
||||
""" return list of all normalized search info labels found in the library """
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.warning(f"SearchInfo not implemented for this library version")
|
||||
return []
|
||||
|
||||
return list(self._db_searchinfo_labels_normalized.keys())
|
||||
|
||||
|
||||
@property
|
||||
def labels_as_dict(self):
|
||||
""" return labels as dict of label: count in reverse sorted order (descending) """
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.warning(f"SearchInfo not implemented for this library version")
|
||||
return dict()
|
||||
|
||||
labels = {k: len(v) for k, v in self._db_searchinfo_labels.items()}
|
||||
labels = dict(sorted(labels.items(), key=lambda kv: kv[1], reverse=True))
|
||||
return labels
|
||||
|
||||
|
||||
@property
|
||||
def labels_normalized_as_dict(self):
|
||||
""" return normalized labels as dict of label: count in reverse sorted order (descending) """
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.warning(f"SearchInfo not implemented for this library version")
|
||||
return dict()
|
||||
labels = {k: len(v) for k, v in self._db_searchinfo_labels_normalized.items()}
|
||||
labels = dict(sorted(labels.items(), key=lambda kv: kv[1], reverse=True))
|
||||
return labels
|
||||
|
||||
|
||||
# The following method is not imported into PhotosDB
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def ints_to_uuid(uuid_0, uuid_1):
|
||||
""" convert two signed ints into a UUID strings
|
||||
uuid_0, uuid_1: the two int components of an RFC 4122 UUID """
|
||||
|
||||
# assumes uuid imported as uuidlib (to avoid namespace conflict with other uses of uuid)
|
||||
|
||||
bytes_ = uuid_0.to_bytes(8, "little", signed=True) + uuid_1.to_bytes(
|
||||
8, "little", signed=True
|
||||
)
|
||||
return str(uuidlib.UUID(bytes=bytes_)).upper()
|
||||
2705
osxphotos/photosdb/photosdb.py
Normal file
84
osxphotos/photosdb/photosdb_utils.py
Normal file
@@ -0,0 +1,84 @@
|
||||
""" utility functions used by PhotosDB """
|
||||
|
||||
import logging
|
||||
import plistlib
|
||||
|
||||
from .._constants import (
|
||||
_PHOTOS_5_MODEL_VERSION,
|
||||
_PHOTOS_6_MODEL_VERSION,
|
||||
_TESTED_DB_VERSIONS,
|
||||
)
|
||||
from ..utils import _open_sql_file
|
||||
|
||||
|
||||
def get_db_version(db_file):
|
||||
""" Gets the Photos DB version from LiGlobals table
|
||||
|
||||
Args:
|
||||
db_file: path to photos.db database file containing LiGlobals table
|
||||
|
||||
Returns: version as str
|
||||
"""
|
||||
|
||||
version = None
|
||||
|
||||
(conn, c) = _open_sql_file(db_file)
|
||||
|
||||
# get database version
|
||||
c.execute("SELECT value from LiGlobals where LiGlobals.keyPath is 'libraryVersion'")
|
||||
version = c.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
if version not in _TESTED_DB_VERSIONS:
|
||||
print(
|
||||
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
|
||||
+ f" You have database version={version} which has not been tested"
|
||||
)
|
||||
|
||||
return version
|
||||
|
||||
|
||||
def get_model_version(db_file):
|
||||
""" Returns the database model version from Z_METADATA
|
||||
|
||||
Args:
|
||||
db_file: path to Photos.sqlite database file containing Z_METADATA table
|
||||
|
||||
Returns: model version as str
|
||||
"""
|
||||
|
||||
version = None
|
||||
|
||||
(conn, c) = _open_sql_file(db_file)
|
||||
|
||||
# get database version
|
||||
c.execute("SELECT MAX(Z_VERSION), Z_PLIST FROM Z_METADATA")
|
||||
results = c.fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
plist = plistlib.loads(results[1])
|
||||
return plist["PLModelVersion"]
|
||||
|
||||
|
||||
def get_db_model_version(db_file):
|
||||
""" Returns Photos version based on model version found in db_file
|
||||
|
||||
Args:
|
||||
db_file: path to Photos.sqlite file
|
||||
|
||||
Returns: int of major Photos version number (e.g. 5 or 6).
|
||||
If unknown model version found, logs warning and returns most current Photos version.
|
||||
"""
|
||||
|
||||
model_ver = get_model_version(db_file)
|
||||
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:
|
||||
db_ver = 5
|
||||
elif _PHOTOS_6_MODEL_VERSION[0] <= model_ver <= _PHOTOS_6_MODEL_VERSION[1]:
|
||||
db_ver = 6
|
||||
else:
|
||||
logging.warning(f"Unknown model version: {model_ver}")
|
||||
# cross our fingers and try latest version
|
||||
db_ver = 6
|
||||
|
||||
return db_ver
|
||||
656
osxphotos/phototemplate.py
Normal file
@@ -0,0 +1,656 @@
|
||||
""" Custom template system for osxphotos (implemented in PhotoInfo.render_template) """
|
||||
|
||||
|
||||
# Rolled my own template system because:
|
||||
# 1. Needed to handle multiple values (e.g. album, keyword)
|
||||
# 2. Needed to handle default values if template not found
|
||||
# 3. Didn't want user to need to know python (e.g. by using Mako which is
|
||||
# already used elsewhere in this project)
|
||||
# 4. Couldn't figure out how to do #1 and #2 with str.format()
|
||||
#
|
||||
# This code isn't elegant but it seems to work well. PRs gladly accepted.
|
||||
import datetime
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import pathlib
|
||||
|
||||
from ._constants import _UNKNOWN_PERSON
|
||||
from .datetime_formatter import DateTimeFormatter
|
||||
|
||||
# ensure locale set to user's locale
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
|
||||
# Permitted substitutions (each of these returns a single value or None)
|
||||
TEMPLATE_SUBSTITUTIONS = {
|
||||
"{name}": "Current filename of the photo",
|
||||
"{original_name}": "Photo's original filename when imported to Photos",
|
||||
"{title}": "Title of the photo",
|
||||
"{descr}": "Description of the photo",
|
||||
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
|
||||
"{created.year}": "4-digit year of file creation time",
|
||||
"{created.yy}": "2-digit year of file creation time",
|
||||
"{created.mm}": "2-digit month of the file creation time (zero padded)",
|
||||
"{created.month}": "Month name in user's locale of the file creation time",
|
||||
"{created.mon}": "Month abbreviation in the user's locale of the file creation time",
|
||||
"{created.dd}": "2-digit day of the month (zero padded) of file creation time",
|
||||
"{created.dow}": "Day of week in user's locale of the file creation time",
|
||||
"{created.doy}": "3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)",
|
||||
"{created.hour}": "2-digit hour of the file creation time",
|
||||
"{created.min}": "2-digit minute of the file creation time",
|
||||
"{created.sec}": "2-digit second of the file creation time",
|
||||
"{created.strftime}": "Apply strftime template to file creation date/time. Should be used in form "
|
||||
+ "{created.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
|
||||
+ "{created.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
|
||||
+ "If used with no template will return null value. "
|
||||
+ "See https://strftime.org/ for help on strftime templates.",
|
||||
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
|
||||
"{modified.year}": "4-digit year of file modification time",
|
||||
"{modified.yy}": "2-digit year of file modification time",
|
||||
"{modified.mm}": "2-digit month of the file modification time (zero padded)",
|
||||
"{modified.month}": "Month name in user's locale of the file modification time",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the file modification time",
|
||||
"{modified.dd}": "2-digit day of the month (zero padded) of the file modification time",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)",
|
||||
"{modified.hour}": "2-digit hour of the file modification time",
|
||||
"{modified.min}": "2-digit minute of the file modification time",
|
||||
"{modified.sec}": "2-digit second of the file modification time",
|
||||
# "{modified.strftime}": "Apply strftime template to file modification date/time. Should be used in form "
|
||||
# + "{modified.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
|
||||
# + "{modified.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
|
||||
# + "If used with no template will return null value. "
|
||||
# + "See https://strftime.org/ for help on strftime templates.",
|
||||
"{today.date}": "Current date in iso format, e.g. '2020-03-22'",
|
||||
"{today.year}": "4-digit year of current date",
|
||||
"{today.yy}": "2-digit year of current date",
|
||||
"{today.mm}": "2-digit month of the current date (zero padded)",
|
||||
"{today.month}": "Month name in user's locale of the current date",
|
||||
"{today.mon}": "Month abbreviation in the user's locale of the current date",
|
||||
"{today.dd}": "2-digit day of the month (zero padded) of current date",
|
||||
"{today.dow}": "Day of week in user's locale of the current date",
|
||||
"{today.doy}": "3-digit day of year (e.g Julian day) of current date, starting from 1 (zero padded)",
|
||||
"{today.hour}": "2-digit hour of the current date",
|
||||
"{today.min}": "2-digit minute of the current date",
|
||||
"{today.sec}": "2-digit second of the current date",
|
||||
"{today.strftime}": "Apply strftime template to current date/time. Should be used in form "
|
||||
+ "{today.strftime,TEMPLATE} where TEMPLATE is a valid strftime template, e.g. "
|
||||
+ "{today.strftime,%Y-%U} would result in year-week number of year: '2020-23'. "
|
||||
+ "If used with no template will return null value. "
|
||||
+ "See https://strftime.org/ for help on strftime templates.",
|
||||
"{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
|
||||
"{place.country_code}": "The ISO country code from the photo's reverse geolocation data",
|
||||
"{place.name.country}": "Country name from the photo's reverse geolocation data",
|
||||
"{place.name.state_province}": "State or province name from the photo's reverse geolocation data",
|
||||
"{place.name.city}": "City or locality name from the photo's reverse geolocation data",
|
||||
"{place.name.area_of_interest}": "Area of interest name (e.g. landmark or public place) from the photo's reverse geolocation data",
|
||||
"{place.address}": "Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States'",
|
||||
"{place.address.street}": "Street part of the postal address, e.g. '2007 18th St NW'",
|
||||
"{place.address.city}": "City part of the postal address, e.g. 'Washington'",
|
||||
"{place.address.state_province}": "State/province part of the postal address, e.g. 'DC'",
|
||||
"{place.address.postal_code}": "Postal code part of the postal address, e.g. '20009'",
|
||||
"{place.address.country}": "Country name of the postal address, e.g. 'United States'",
|
||||
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
|
||||
}
|
||||
|
||||
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
"{album}": "Album(s) photo is contained in",
|
||||
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
|
||||
"{keyword}": "Keyword(s) assigned to photo",
|
||||
"{person}": "Person(s) / face(s) in a photo",
|
||||
"{label}": "Image categorization label associated with a photo (Photos 5 only)",
|
||||
"{label_normalized}": "All lower case version of 'label' (Photos 5 only)",
|
||||
}
|
||||
|
||||
# Just the multi-valued substitution names without the braces
|
||||
MULTI_VALUE_SUBSTITUTIONS = [
|
||||
field.replace("{", "").replace("}", "")
|
||||
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
|
||||
]
|
||||
|
||||
|
||||
class PhotoTemplate:
|
||||
""" PhotoTemplate class to render a template string from a PhotoInfo object """
|
||||
|
||||
def __init__(self, photo):
|
||||
""" Inits PhotoTemplate class with photo, non_str, and path_sep
|
||||
|
||||
Args:
|
||||
photo: a PhotoInfo instance.
|
||||
"""
|
||||
self.photo = photo
|
||||
|
||||
# holds value of current date/time for {today.x} fields
|
||||
# gets initialized in get_template_value
|
||||
self.today = None
|
||||
|
||||
def render(
|
||||
self,
|
||||
template,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
):
|
||||
""" Render a filename or directory template
|
||||
|
||||
Args:
|
||||
template: str template
|
||||
none_str: str to use default for None values, default is '_'
|
||||
path_sep: optional character to use as path separator, default is os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
"""
|
||||
|
||||
if path_sep is None:
|
||||
path_sep = os.path.sep
|
||||
elif path_sep is not None and len(path_sep) != 1:
|
||||
raise ValueError(f"path_sep must be single character: {path_sep}")
|
||||
|
||||
if inplace_sep is None:
|
||||
inplace_sep = ","
|
||||
|
||||
# the rendering happens in two phases:
|
||||
# phase 1: handle all the single-value template substitutions
|
||||
# results in a single string with all the template fields replaced
|
||||
# phase 2: loop through all the multi-value template substitutions
|
||||
# could result in multiple strings
|
||||
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
|
||||
# there would be 6 possible renderings (2 albums x 3 persons)
|
||||
|
||||
# regex to find {template_field,optional_default} in strings
|
||||
# for explanation of regex see https://regex101.com/r/4JJg42/1
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-\%. ]+))?)(?=\}(?!\}))\}"
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
def make_subst_function(self, none_str, get_func=self.get_template_value):
|
||||
""" returns: substitution function for use in re.sub
|
||||
none_str: value to use if substitution lookup is None and no default provided
|
||||
get_func: function that gets the substitution value for a given template field
|
||||
default is get_template_value which handles the single-value fields """
|
||||
|
||||
# closure to capture photo, none_str in subst
|
||||
def subst(matchobj):
|
||||
groups = len(matchobj.groups())
|
||||
if groups == 4:
|
||||
try:
|
||||
val = get_func(matchobj.group(1), matchobj.group(3))
|
||||
except ValueError:
|
||||
return matchobj.group(0)
|
||||
|
||||
if val is None:
|
||||
return (
|
||||
matchobj.group(3)
|
||||
if matchobj.group(3) is not None
|
||||
else none_str
|
||||
)
|
||||
else:
|
||||
return val
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected number of groups: expected 4, got {groups}"
|
||||
)
|
||||
|
||||
return subst
|
||||
|
||||
subst_func = make_subst_function(self, none_str)
|
||||
|
||||
# do the replacements
|
||||
rendered = re.sub(regex, subst_func, template)
|
||||
|
||||
# do multi-valued placements
|
||||
# start with the single string from phase 1 above then loop through all
|
||||
# multi-valued fields and all values for each of those fields
|
||||
# rendered_strings will be updated as each field is processed
|
||||
# for example: if two albums, two keywords, and one person and template is:
|
||||
# "{created.year}/{album}/{keyword}/{person}"
|
||||
# rendered strings would do the following:
|
||||
# start (created.year filled in phase 1)
|
||||
# ['2011/{album}/{keyword}/{person}']
|
||||
# after processing albums:
|
||||
# ['2011/Album1/{keyword}/{person}',
|
||||
# '2011/Album2/{keyword}/{person}',]
|
||||
# after processing keywords:
|
||||
# ['2011/Album1/keyword1/{person}',
|
||||
# '2011/Album1/keyword2/{person}',
|
||||
# '2011/Album2/keyword1/{person}',
|
||||
# '2011/Album2/keyword2/{person}',]
|
||||
# after processing person:
|
||||
# ['2011/Album1/keyword1/person1',
|
||||
# '2011/Album1/keyword2/person1',
|
||||
# '2011/Album2/keyword1/person1',
|
||||
# '2011/Album2/keyword2/person1',]
|
||||
|
||||
rendered_strings = set([rendered])
|
||||
for field in MULTI_VALUE_SUBSTITUTIONS:
|
||||
# Build a regex that matches only the field being processed
|
||||
re_str = r"(?<!\\)\{(" + field + r")(,(([\w\-\%. ]{0,})))?\}"
|
||||
regex_multi = re.compile(re_str)
|
||||
|
||||
# holds each of the new rendered_strings, set() to avoid duplicates
|
||||
new_strings = set()
|
||||
|
||||
for str_template in rendered_strings:
|
||||
if regex_multi.search(str_template):
|
||||
values = self.get_template_value_multi(field, path_sep)
|
||||
if expand_inplace:
|
||||
# instead of returning multiple strings, join values into a single string
|
||||
val = (
|
||||
inplace_sep.join(sorted(values))
|
||||
if values and values[0]
|
||||
else None
|
||||
)
|
||||
|
||||
def lookup_template_value_multi(lookup_value, default):
|
||||
""" Closure passed to make_subst_function get_func
|
||||
Capture val and field in the closure
|
||||
Allows make_subst_function to be re-used w/o modification
|
||||
default is not used but required so signature matches get_template_value """
|
||||
if lookup_value == field:
|
||||
return val
|
||||
else:
|
||||
raise ValueError(f"Unexpected value: {lookup_value}")
|
||||
|
||||
subst = make_subst_function(
|
||||
self, none_str, get_func=lookup_template_value_multi
|
||||
)
|
||||
new_string = regex_multi.sub(subst, str_template)
|
||||
|
||||
# update rendered_strings for the next field to process
|
||||
rendered_strings = {new_string}
|
||||
else:
|
||||
# create a new template string for each value
|
||||
for val in values:
|
||||
|
||||
def lookup_template_value_multi(lookup_value, default):
|
||||
""" Closure passed to make_subst_function get_func
|
||||
Capture val and field in the closure
|
||||
Allows make_subst_function to be re-used w/o modification
|
||||
default is not used but required so signature matches get_template_value """
|
||||
if lookup_value == field:
|
||||
return val
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected value: {lookup_value}"
|
||||
)
|
||||
|
||||
subst = make_subst_function(
|
||||
self, none_str, get_func=lookup_template_value_multi
|
||||
)
|
||||
new_string = regex_multi.sub(subst, str_template)
|
||||
new_strings.add(new_string)
|
||||
|
||||
# update rendered_strings for the next field to process
|
||||
rendered_strings = new_strings
|
||||
|
||||
# find any {fields} that weren't replaced
|
||||
unmatched = []
|
||||
for rendered_str in rendered_strings:
|
||||
unmatched.extend(
|
||||
[
|
||||
no_match[0]
|
||||
for no_match in re.findall(regex, rendered_str)
|
||||
if no_match[0] not in unmatched
|
||||
]
|
||||
)
|
||||
|
||||
# fix any escaped curly braces
|
||||
rendered_strings = [
|
||||
rendered_str.replace("{{", "{").replace("}}", "}")
|
||||
for rendered_str in rendered_strings
|
||||
]
|
||||
|
||||
return rendered_strings, unmatched
|
||||
|
||||
def get_template_value(self, field, default):
|
||||
"""lookup value for template field (single-value template substitutions)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
default: the default value provided by the user
|
||||
|
||||
Returns:
|
||||
The matching template value (which may be None).
|
||||
|
||||
Raises:
|
||||
ValueError if no rule exists for field.
|
||||
"""
|
||||
|
||||
# initialize today with current date/time if needed
|
||||
if self.today is None:
|
||||
self.today = datetime.datetime.now()
|
||||
|
||||
# must be a valid keyword
|
||||
if field == "name":
|
||||
return pathlib.Path(self.photo.filename).stem
|
||||
|
||||
if field == "original_name":
|
||||
return pathlib.Path(self.photo.original_filename).stem
|
||||
|
||||
if field == "title":
|
||||
return self.photo.title
|
||||
|
||||
if field == "descr":
|
||||
return self.photo.description
|
||||
|
||||
if field == "created.date":
|
||||
return DateTimeFormatter(self.photo.date).date
|
||||
|
||||
if field == "created.year":
|
||||
return DateTimeFormatter(self.photo.date).year
|
||||
|
||||
if field == "created.yy":
|
||||
return DateTimeFormatter(self.photo.date).yy
|
||||
|
||||
if field == "created.mm":
|
||||
return DateTimeFormatter(self.photo.date).mm
|
||||
|
||||
if field == "created.month":
|
||||
return DateTimeFormatter(self.photo.date).month
|
||||
|
||||
if field == "created.mon":
|
||||
return DateTimeFormatter(self.photo.date).mon
|
||||
|
||||
if field == "created.dd":
|
||||
return DateTimeFormatter(self.photo.date).dd
|
||||
|
||||
if field == "created.dow":
|
||||
return DateTimeFormatter(self.photo.date).dow
|
||||
|
||||
if field == "created.doy":
|
||||
return DateTimeFormatter(self.photo.date).doy
|
||||
|
||||
if field == "created.hour":
|
||||
return DateTimeFormatter(self.photo.date).hour
|
||||
|
||||
if field == "created.min":
|
||||
return DateTimeFormatter(self.photo.date).min
|
||||
|
||||
if field == "created.sec":
|
||||
return DateTimeFormatter(self.photo.date).sec
|
||||
|
||||
if field == "created.strftime":
|
||||
if default:
|
||||
try:
|
||||
return self.photo.date.strftime(default)
|
||||
except:
|
||||
raise ValueError(f"Invalid strftime template: '{default}'")
|
||||
else:
|
||||
return None
|
||||
|
||||
if field == "modified.date":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).date
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.year":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).year
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.yy":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).yy
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.mm":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).mm
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.month":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).month
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.mon":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).mon
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.dd":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).dd
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.doy":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).doy
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.hour":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).hour
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.min":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).min
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "modified.sec":
|
||||
return (
|
||||
DateTimeFormatter(self.photo.date_modified).sec
|
||||
if self.photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
# TODO: disabling modified.strftime for now because now clean way to pass
|
||||
# a default value if modified time is None
|
||||
# if field == "modified.strftime":
|
||||
# if default and self.photo.date_modified:
|
||||
# try:
|
||||
# return self.photo.date_modified.strftime(default)
|
||||
# except:
|
||||
# raise ValueError(f"Invalid strftime template: '{default}'")
|
||||
# else:
|
||||
# return None
|
||||
|
||||
if field == "today.date":
|
||||
return DateTimeFormatter(self.today).date
|
||||
|
||||
if field == "today.year":
|
||||
return DateTimeFormatter(self.today).year
|
||||
|
||||
if field == "today.yy":
|
||||
return DateTimeFormatter(self.today).yy
|
||||
|
||||
if field == "today.mm":
|
||||
return DateTimeFormatter(self.today).mm
|
||||
|
||||
if field == "today.month":
|
||||
return DateTimeFormatter(self.today).month
|
||||
|
||||
if field == "today.mon":
|
||||
return DateTimeFormatter(self.today).mon
|
||||
|
||||
if field == "today.dd":
|
||||
return DateTimeFormatter(self.today).dd
|
||||
|
||||
if field == "today.dow":
|
||||
return DateTimeFormatter(self.today).dow
|
||||
|
||||
if field == "today.doy":
|
||||
return DateTimeFormatter(self.today).doy
|
||||
|
||||
if field == "today.hour":
|
||||
return DateTimeFormatter(self.today).hour
|
||||
|
||||
if field == "today.min":
|
||||
return DateTimeFormatter(self.today).min
|
||||
|
||||
if field == "today.sec":
|
||||
return DateTimeFormatter(self.today).sec
|
||||
|
||||
if field == "today.strftime":
|
||||
if default:
|
||||
try:
|
||||
return self.today.strftime(default)
|
||||
except:
|
||||
raise ValueError(f"Invalid strftime template: '{default}'")
|
||||
else:
|
||||
return None
|
||||
|
||||
if field == "place.name":
|
||||
return self.photo.place.name if self.photo.place else None
|
||||
|
||||
if field == "place.country_code":
|
||||
return self.photo.place.country_code if self.photo.place else None
|
||||
|
||||
if field == "place.name.country":
|
||||
return (
|
||||
self.photo.place.names.country[0]
|
||||
if self.photo.place and self.photo.place.names.country
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.name.state_province":
|
||||
return (
|
||||
self.photo.place.names.state_province[0]
|
||||
if self.photo.place and self.photo.place.names.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.name.city":
|
||||
return (
|
||||
self.photo.place.names.city[0]
|
||||
if self.photo.place and self.photo.place.names.city
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.name.area_of_interest":
|
||||
return (
|
||||
self.photo.place.names.area_of_interest[0]
|
||||
if self.photo.place and self.photo.place.names.area_of_interest
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address":
|
||||
return (
|
||||
self.photo.place.address_str
|
||||
if self.photo.place and self.photo.place.address_str
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.street":
|
||||
return (
|
||||
self.photo.place.address.street
|
||||
if self.photo.place and self.photo.place.address.street
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.city":
|
||||
return (
|
||||
self.photo.place.address.city
|
||||
if self.photo.place and self.photo.place.address.city
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.state_province":
|
||||
return (
|
||||
self.photo.place.address.state_province
|
||||
if self.photo.place and self.photo.place.address.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.postal_code":
|
||||
return (
|
||||
self.photo.place.address.postal_code
|
||||
if self.photo.place and self.photo.place.address.postal_code
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.country":
|
||||
return (
|
||||
self.photo.place.address.country
|
||||
if self.photo.place and self.photo.place.address.country
|
||||
else None
|
||||
)
|
||||
|
||||
if field == "place.address.country_code":
|
||||
return (
|
||||
self.photo.place.address.iso_country_code
|
||||
if self.photo.place and self.photo.place.address.iso_country_code
|
||||
else None
|
||||
)
|
||||
|
||||
# if here, didn't get a match
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
def get_template_value_multi(self, field, path_sep):
|
||||
"""lookup value for template field (multi-value template substitutions)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
path_sep: path separator to use for folder_album field
|
||||
|
||||
Returns:
|
||||
List of the matching template values or [None].
|
||||
|
||||
Raises:
|
||||
ValueError if no rule exists for field.
|
||||
"""
|
||||
|
||||
""" return list of values for a multi-valued template field """
|
||||
if field == "album":
|
||||
values = self.photo.albums
|
||||
values = [
|
||||
value.replace("/", ":") for value in values
|
||||
] # TODO: temp fix for issue #213
|
||||
elif field == "keyword":
|
||||
values = self.photo.keywords
|
||||
elif field == "person":
|
||||
values = self.photo.persons
|
||||
# remove any _UNKNOWN_PERSON values
|
||||
values = [val for val in values if val != _UNKNOWN_PERSON]
|
||||
elif field == "label":
|
||||
values = self.photo.labels
|
||||
elif field == "label_normalized":
|
||||
values = self.photo.labels_normalized
|
||||
elif field == "folder_album":
|
||||
values = []
|
||||
# photos must be in an album to be in a folder
|
||||
for album in self.photo.album_info:
|
||||
if album.folder_names:
|
||||
# album in folder
|
||||
folder = path_sep.join(album.folder_names)
|
||||
folder += path_sep + album.title.replace(
|
||||
"/", ":"
|
||||
) # TODO: temp fix for issue #213
|
||||
values.append(folder)
|
||||
else:
|
||||
# album not in folder
|
||||
values.append(album.title.replace("/", ":"))
|
||||
else:
|
||||
raise ValueError(f"Unhandleded template value: {field}")
|
||||
|
||||
# If no values, insert None so code below will substite none_str for None
|
||||
values = values or [None]
|
||||
return values
|
||||
@@ -11,6 +11,9 @@ from collections import namedtuple # pylint: disable=syntax-error
|
||||
import yaml
|
||||
from bpylist import archiver
|
||||
|
||||
from ._constants import UNICODE_FORMAT
|
||||
from .utils import normalize_unicode
|
||||
|
||||
# postal address information, returned by PlaceInfo.address
|
||||
PostalAddress = namedtuple(
|
||||
"PostalAddress",
|
||||
@@ -76,30 +79,30 @@ class PLRevGeoLocationInfo:
|
||||
geoServiceProvider,
|
||||
postalAddress,
|
||||
):
|
||||
self.addressString = addressString
|
||||
self.addressString = normalize_unicode(addressString)
|
||||
self.countryCode = countryCode
|
||||
self.mapItem = mapItem
|
||||
self.isHome = isHome
|
||||
self.compoundNames = compoundNames
|
||||
self.compoundSecondaryNames = compoundSecondaryNames
|
||||
self.compoundNames = normalize_unicode(compoundNames)
|
||||
self.compoundSecondaryNames = normalize_unicode(compoundSecondaryNames)
|
||||
self.version = version
|
||||
self.geoServiceProvider = geoServiceProvider
|
||||
self.postalAddress = postalAddress
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in [
|
||||
"addressString",
|
||||
"countryCode",
|
||||
"isHome",
|
||||
"compoundNames",
|
||||
"compoundSecondaryNames",
|
||||
"version",
|
||||
"geoServiceProvider",
|
||||
"postalAddress",
|
||||
]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field)
|
||||
for field in [
|
||||
"addressString",
|
||||
"countryCode",
|
||||
"isHome",
|
||||
"compoundNames",
|
||||
"compoundSecondaryNames",
|
||||
"version",
|
||||
"geoServiceProvider",
|
||||
"postalAddress",
|
||||
]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
@@ -151,21 +154,17 @@ class PLRevGeoMapItem:
|
||||
self.finalPlaceInfos = finalPlaceInfos
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in ["sortedPlaceInfos", "finalPlaceInfos"]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field)
|
||||
for field in ["sortedPlaceInfos", "finalPlaceInfos"]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
sortedPlaceInfos = []
|
||||
finalPlaceInfos = []
|
||||
for place in self.sortedPlaceInfos:
|
||||
sortedPlaceInfos.append(str(place))
|
||||
for place in self.finalPlaceInfos:
|
||||
finalPlaceInfos.append(str(place))
|
||||
sortedPlaceInfos = [str(place) for place in self.sortedPlaceInfos]
|
||||
finalPlaceInfos = [str(place) for place in self.finalPlaceInfos]
|
||||
return (
|
||||
f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
|
||||
)
|
||||
@@ -187,15 +186,15 @@ class PLRevGeoMapItemAdditionalPlaceInfo:
|
||||
|
||||
def __init__(self, area, name, placeType, dominantOrderType):
|
||||
self.area = area
|
||||
self.name = name
|
||||
self.name = normalize_unicode(name)
|
||||
self.placeType = placeType
|
||||
self.dominantOrderType = dominantOrderType
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in ["area", "name", "placeType", "dominantOrderType"]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field)
|
||||
for field in ["area", "name", "placeType", "dominantOrderType"]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
@@ -236,28 +235,28 @@ class CNPostalAddress:
|
||||
_subLocality,
|
||||
):
|
||||
self._ISOCountryCode = _ISOCountryCode
|
||||
self._city = _city
|
||||
self._country = _country
|
||||
self._postalCode = _postalCode
|
||||
self._state = _state
|
||||
self._street = _street
|
||||
self._subAdministrativeArea = _subAdministrativeArea
|
||||
self._subLocality = _subLocality
|
||||
self._city = normalize_unicode(_city)
|
||||
self._country = normalize_unicode(_country)
|
||||
self._postalCode = normalize_unicode(_postalCode)
|
||||
self._state = normalize_unicode(_state)
|
||||
self._street = normalize_unicode(_street)
|
||||
self._subAdministrativeArea = normalize_unicode(_subAdministrativeArea)
|
||||
self._subLocality = normalize_unicode(_subLocality)
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in [
|
||||
"_ISOCountryCode",
|
||||
"_city",
|
||||
"_country",
|
||||
"_postalCode",
|
||||
"_state",
|
||||
"_street",
|
||||
"_subAdministrativeArea",
|
||||
"_subLocality",
|
||||
]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field)
|
||||
for field in [
|
||||
"_ISOCountryCode",
|
||||
"_city",
|
||||
"_country",
|
||||
"_postalCode",
|
||||
"_state",
|
||||
"_street",
|
||||
"_subAdministrativeArea",
|
||||
"_subLocality",
|
||||
]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
@@ -418,9 +417,9 @@ class PlaceInfo4(PlaceInfo):
|
||||
# 2: type
|
||||
# 3: area
|
||||
try:
|
||||
places_dict[p[2]].append((p[1], p[3]))
|
||||
places_dict[p[2]].append((normalize_unicode(p[1]), p[3]))
|
||||
except KeyError:
|
||||
places_dict[p[2]] = [(p[1], p[3])]
|
||||
places_dict[p[2]] = [(normalize_unicode(p[1]), p[3])]
|
||||
|
||||
# build list to populate PlaceNames tuple
|
||||
# initialize with empty lists for each field in PlaceNames
|
||||
@@ -490,8 +489,14 @@ class PlaceInfo4(PlaceInfo):
|
||||
"names": self.names,
|
||||
"country_code": self.country_code,
|
||||
}
|
||||
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
return strval
|
||||
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"names": self.names._asdict(),
|
||||
"country_code": self.country_code,
|
||||
}
|
||||
|
||||
|
||||
class PlaceInfo5(PlaceInfo):
|
||||
@@ -501,7 +506,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()
|
||||
|
||||
@@ -533,17 +537,23 @@ class PlaceInfo5(PlaceInfo):
|
||||
@property
|
||||
def address(self):
|
||||
addr = self._plrevgeoloc.postalAddress
|
||||
address = PostalAddress(
|
||||
street=addr._street,
|
||||
sub_locality=addr._subLocality,
|
||||
city=addr._city,
|
||||
sub_administrative_area=addr._subAdministrativeArea,
|
||||
state_province=addr._state,
|
||||
postal_code=addr._postalCode,
|
||||
country=addr._country,
|
||||
iso_country_code=addr._ISOCountryCode,
|
||||
)
|
||||
return address
|
||||
if addr is not None:
|
||||
postal_address = PostalAddress(
|
||||
street=addr._street,
|
||||
sub_locality=addr._subLocality,
|
||||
city=addr._city,
|
||||
sub_administrative_area=addr._subAdministrativeArea,
|
||||
state_province=addr._state,
|
||||
postal_code=addr._postalCode,
|
||||
country=addr._country,
|
||||
iso_country_code=addr._ISOCountryCode,
|
||||
)
|
||||
else:
|
||||
postal_address = PostalAddress(
|
||||
None, None, None, None, None, None, None, None
|
||||
)
|
||||
|
||||
return postal_address
|
||||
|
||||
def _process_place_info(self):
|
||||
""" Process sortedPlaceInfos to set self._name and self._names """
|
||||
@@ -622,5 +632,14 @@ class PlaceInfo5(PlaceInfo):
|
||||
"address_str": self.address_str,
|
||||
"address": str(self.address),
|
||||
}
|
||||
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
return strval
|
||||
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"names": self.names._asdict(),
|
||||
"country_code": self.country_code,
|
||||
"ishome": self.ishome,
|
||||
"address_str": self.address_str,
|
||||
"address": self.address._asdict() if self.address is not None else None,
|
||||
}
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
""" Custom template system for osxphotos """
|
||||
|
||||
# Rolled my own template system because:
|
||||
# 1. Needed to handle multiple values (e.g. album, keyword)
|
||||
# 2. Needed to handle default values if template not found
|
||||
# 3. Didn't want user to need to know python (e.g. by using Mako which is
|
||||
# already used elsewhere in this project)
|
||||
# 4. Couldn't figure out how to do #1 and #2 with str.format()
|
||||
#
|
||||
# This code isn't elegant but it seems to work well. PRs gladly accepted.
|
||||
|
||||
import datetime
|
||||
import pathlib
|
||||
import re
|
||||
from typing import Tuple, List # pylint: disable=syntax-error
|
||||
|
||||
from .photoinfo import PhotoInfo
|
||||
from ._constants import _UNKNOWN_PERSON
|
||||
|
||||
# Permitted substitutions (each of these returns a single value or None)
|
||||
TEMPLATE_SUBSTITUTIONS = {
|
||||
"{name}": "Filename of the photo",
|
||||
"{original_name}": "Photo's original filename when imported to Photos",
|
||||
"{title}": "Title of the photo",
|
||||
"{descr}": "Description of the photo",
|
||||
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
|
||||
"{created.year}": "4-digit year of file creation time",
|
||||
"{created.yy}": "2-digit year of file creation time",
|
||||
"{created.mm}": "2-digit month of the file creation time (zero padded)",
|
||||
"{created.month}": "Month name in user's locale of the file creation time",
|
||||
"{created.mon}": "Month abbreviation in the user's locale of the file creation time",
|
||||
"{created.doy}": "3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)",
|
||||
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
|
||||
"{modified.year}": "4-digit year of file modification time",
|
||||
"{modified.yy}": "2-digit year of file modification time",
|
||||
"{modified.mm}": "2-digit month of the file modification time (zero padded)",
|
||||
"{modified.month}": "Month name in user's locale of the file modification time",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the file modification time",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)",
|
||||
"{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
|
||||
"{place.country_code}": "The ISO country code from the photo's reverse geolocation data",
|
||||
"{place.name.country}": "Country name from the photo's reverse geolocation data",
|
||||
"{place.name.state_province}": "State or province name from the photo's reverse geolocation data",
|
||||
"{place.name.city}": "City or locality name from the photo's reverse geolocation data",
|
||||
"{place.name.area_of_interest}": "Area of interest name (e.g. landmark or public place) from the photo's reverse geolocation data",
|
||||
"{place.address}": "Postal address from the photo's reverse geolocation data, e.g. '2007 18th St NW, Washington, DC 20009, United States'",
|
||||
"{place.address.street}": "Street part of the postal address, e.g. '2007 18th St NW'",
|
||||
"{place.address.city}": "City part of the postal address, e.g. 'Washington'",
|
||||
"{place.address.state_province}": "State/province part of the postal address, e.g. 'DC'",
|
||||
"{place.address.postal_code}": "Postal code part of the postal address, e.g. '20009'",
|
||||
"{place.address.country}": "Country name of the postal address, e.g. 'United States'",
|
||||
"{place.address.country_code}": "ISO country code of the postal address, e.g. 'US'",
|
||||
}
|
||||
|
||||
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
"{album}": "Album(s) photo is contained in",
|
||||
"{keyword}": "Keyword(s) assigned to photo",
|
||||
"{person}": "Person(s) / face(s) in a photo",
|
||||
}
|
||||
|
||||
# Just the multi-valued substitution names without the braces
|
||||
MULTI_VALUE_SUBSTITUTIONS = [
|
||||
field.replace("{", "").replace("}", "")
|
||||
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED.keys()
|
||||
]
|
||||
|
||||
|
||||
def get_template_value(lookup, photo):
|
||||
""" lookup template value (single-value template substitutions) for use in make_subst_function
|
||||
lookup: value to find a match for
|
||||
photo: PhotoInfo object whose data will be used for value substitutions
|
||||
returns: either the matching template value (which may be None)
|
||||
raises: KeyError if no rule exists for lookup """
|
||||
|
||||
# must be a valid keyword
|
||||
if lookup == "name":
|
||||
return pathlib.Path(photo.filename).stem
|
||||
|
||||
if lookup == "original_name":
|
||||
return pathlib.Path(photo.original_filename).stem
|
||||
|
||||
if lookup == "title":
|
||||
return photo.title
|
||||
|
||||
if lookup == "descr":
|
||||
return photo.description
|
||||
|
||||
if lookup == "created.date":
|
||||
return DateTimeFormatter(photo.date).date
|
||||
|
||||
if lookup == "created.year":
|
||||
return DateTimeFormatter(photo.date).year
|
||||
|
||||
if lookup == "created.yy":
|
||||
return DateTimeFormatter(photo.date).yy
|
||||
|
||||
if lookup == "created.mm":
|
||||
return DateTimeFormatter(photo.date).mm
|
||||
|
||||
if lookup == "created.month":
|
||||
return DateTimeFormatter(photo.date).month
|
||||
|
||||
if lookup == "created.mon":
|
||||
return DateTimeFormatter(photo.date).mon
|
||||
|
||||
if lookup == "created.doy":
|
||||
return DateTimeFormatter(photo.date).doy
|
||||
|
||||
if lookup == "modified.date":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).date if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.year":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).year if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.yy":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).yy if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.mm":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).mm if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.month":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).month
|
||||
if photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "modified.mon":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).mon if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.doy":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).doy if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "place.name":
|
||||
return photo.place.name if photo.place else None
|
||||
|
||||
if lookup == "place.country_code":
|
||||
return photo.place.country_code if photo.place else None
|
||||
|
||||
if lookup == "place.name.country":
|
||||
return (
|
||||
photo.place.names.country[0]
|
||||
if photo.place and photo.place.names.country
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.name.state_province":
|
||||
return (
|
||||
photo.place.names.state_province[0]
|
||||
if photo.place and photo.place.names.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.name.city":
|
||||
return (
|
||||
photo.place.names.city[0]
|
||||
if photo.place and photo.place.names.city
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.name.area_of_interest":
|
||||
return (
|
||||
photo.place.names.area_of_interest[0]
|
||||
if photo.place and photo.place.names.area_of_interest
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address":
|
||||
return (
|
||||
photo.place.address_str if photo.place and photo.place.address_str else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.street":
|
||||
return (
|
||||
photo.place.address.street
|
||||
if photo.place and photo.place.address.street
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.city":
|
||||
return (
|
||||
photo.place.address.city
|
||||
if photo.place and photo.place.address.city
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.state_province":
|
||||
return (
|
||||
photo.place.address.state_province
|
||||
if photo.place and photo.place.address.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.postal_code":
|
||||
return (
|
||||
photo.place.address.postal_code
|
||||
if photo.place and photo.place.address.postal_code
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.country":
|
||||
return (
|
||||
photo.place.address.country
|
||||
if photo.place and photo.place.address.country
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.country_code":
|
||||
return (
|
||||
photo.place.address.iso_country_code
|
||||
if photo.place and photo.place.address.iso_country_code
|
||||
else None
|
||||
)
|
||||
|
||||
# if here, didn't get a match
|
||||
raise KeyError(f"No rule for processing {lookup}")
|
||||
|
||||
|
||||
def render_filepath_template(template, photo, none_str="_"):
|
||||
""" render a filename or directory template
|
||||
template: str template
|
||||
photo: PhotoInfo object
|
||||
none_str: str to use default for None values, default is '_' """
|
||||
|
||||
# the rendering happens in two phases:
|
||||
# phase 1: handle all the single-value template substitutions
|
||||
# results in a single string with all the template fields replaced
|
||||
# phase 2: loop through all the multi-value template substitutions
|
||||
# could result in multiple strings
|
||||
# e.g. if template is "{album}/{person}" and there are 2 albums and 3 persons in the photo
|
||||
# there would be 6 possible renderings (2 albums x 3 persons)
|
||||
|
||||
# regex to find {template_field,optional_default} in strings
|
||||
# for explanation of regex see https://regex101.com/r/4JJg42/1
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
regex = r"(?<!\{)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)(?=\}(?!\}))\}"
|
||||
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
if type(photo) is not PhotoInfo:
|
||||
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
|
||||
|
||||
def make_subst_function(photo, none_str, get_func=get_template_value):
|
||||
""" returns: substitution function for use in re.sub
|
||||
photo: a PhotoInfo object
|
||||
none_str: value to use if substitution lookup is None and no default provided
|
||||
get_func: function that gets the substitution value for a given template field
|
||||
default is get_template_value which handles the single-value fields """
|
||||
|
||||
# closure to capture photo, none_str in subst
|
||||
def subst(matchobj):
|
||||
groups = len(matchobj.groups())
|
||||
if groups == 4:
|
||||
try:
|
||||
val = get_func(matchobj.group(1), photo)
|
||||
except KeyError:
|
||||
return matchobj.group(0)
|
||||
|
||||
if val is None:
|
||||
return (
|
||||
matchobj.group(3) if matchobj.group(3) is not None else none_str
|
||||
)
|
||||
else:
|
||||
return val
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected number of groups: expected 4, got {groups}"
|
||||
)
|
||||
|
||||
return subst
|
||||
|
||||
subst_func = make_subst_function(photo, none_str)
|
||||
|
||||
# do the replacements
|
||||
rendered = re.sub(regex, subst_func, template)
|
||||
|
||||
# do multi-valued placements
|
||||
# start with the single string from phase 1 above then loop through all
|
||||
# multi-valued fields and all values for each of those fields
|
||||
# rendered_strings will be updated as each field is processed
|
||||
# for example: if two albums, two keywords, and one person and template is:
|
||||
# "{created.year}/{album}/{keyword}/{person}"
|
||||
# rendered strings would do the following:
|
||||
# start (created.year filled in phase 1)
|
||||
# ['2011/{album}/{keyword}/{person}']
|
||||
# after processing albums:
|
||||
# ['2011/Album1/{keyword}/{person}',
|
||||
# '2011/Album2/{keyword}/{person}',]
|
||||
# after processing keywords:
|
||||
# ['2011/Album1/keyword1/{person}',
|
||||
# '2011/Album1/keyword2/{person}',
|
||||
# '2011/Album2/keyword1/{person}',
|
||||
# '2011/Album2/keyword2/{person}',]
|
||||
# after processing person:
|
||||
# ['2011/Album1/keyword1/person1',
|
||||
# '2011/Album1/keyword2/person1',
|
||||
# '2011/Album2/keyword1/person1',
|
||||
# '2011/Album2/keyword2/person1',]
|
||||
|
||||
rendered_strings = set([rendered])
|
||||
for field in MULTI_VALUE_SUBSTITUTIONS:
|
||||
if field == "album":
|
||||
values = photo.albums
|
||||
elif field == "keyword":
|
||||
values = photo.keywords
|
||||
elif field == "person":
|
||||
values = photo.persons
|
||||
# remove any _UNKNOWN_PERSON values
|
||||
values = [val for val in values if val != _UNKNOWN_PERSON]
|
||||
else:
|
||||
raise ValueError(f"Unhandleded template value: {field}")
|
||||
|
||||
# If no values, insert None so code below will substite none_str for None
|
||||
values = values or [None]
|
||||
|
||||
# Build a regex that matches only the field being processed
|
||||
re_str = r"(?<!\\)\{(" + field + r")(,{0,1}(([\w\-. ]+))?)\}"
|
||||
regex_multi = re.compile(re_str)
|
||||
|
||||
# holds each of the new rendered_strings, set() to avoid duplicates
|
||||
new_strings = set()
|
||||
|
||||
for str_template in rendered_strings:
|
||||
for val in values:
|
||||
|
||||
def get_template_value_multi(lookup_value, photo):
|
||||
""" Closure passed to make_subst_function get_func
|
||||
Capture val and field in the closure
|
||||
Allows make_subst_function to be re-used w/o modification """
|
||||
if lookup_value == field:
|
||||
return val
|
||||
else:
|
||||
raise KeyError(f"Unexpected value: {lookup_value}")
|
||||
|
||||
subst = make_subst_function(
|
||||
photo, none_str, get_func=get_template_value_multi
|
||||
)
|
||||
new_string = regex_multi.sub(subst, str_template)
|
||||
new_strings.add(new_string)
|
||||
|
||||
# update rendered_strings for the next field to process
|
||||
rendered_strings = new_strings
|
||||
|
||||
# find any {fields} that weren't replaced
|
||||
unmatched = []
|
||||
for rendered_str in rendered_strings:
|
||||
unmatched.extend(
|
||||
[
|
||||
no_match[0]
|
||||
for no_match in re.findall(regex, rendered_str)
|
||||
if no_match[0] not in unmatched
|
||||
]
|
||||
)
|
||||
|
||||
# fix any escaped curly braces
|
||||
rendered_strings = [
|
||||
rendered_str.replace("{{", "{").replace("}}", "}")
|
||||
for rendered_str in rendered_strings
|
||||
]
|
||||
|
||||
return rendered_strings, unmatched
|
||||
|
||||
|
||||
class DateTimeFormatter:
|
||||
""" provides property access to formatted datetime.datetime strftime values """
|
||||
|
||||
def __init__(self, dt: datetime.datetime):
|
||||
self.dt = dt
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
""" ISO date in form 2020-03-22 """
|
||||
date = self.dt.date().isoformat()
|
||||
return date
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" 4 digit year """
|
||||
year = f"{self.dt.year}"
|
||||
return year
|
||||
|
||||
@property
|
||||
def yy(self):
|
||||
""" 2 digit year """
|
||||
yy = f"{self.dt.strftime('%y')}"
|
||||
return yy
|
||||
|
||||
@property
|
||||
def mm(self):
|
||||
""" 2 digit month """
|
||||
mm = f"{self.dt.strftime('%m')}"
|
||||
return mm
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" Month as locale's full name """
|
||||
month = f"{self.dt.strftime('%B')}"
|
||||
return month
|
||||
|
||||
@property
|
||||
def mon(self):
|
||||
""" Month as locale's abbreviated name """
|
||||
mon = f"{self.dt.strftime('%b')}"
|
||||
return mon
|
||||
|
||||
@property
|
||||
def doy(self):
|
||||
""" Julian day of year starting from 001 """
|
||||
doy = f"{self.dt.strftime('%j')}"
|
||||
return doy
|
||||
@@ -1,5 +1,13 @@
|
||||
<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
|
||||
<%def name="photoshop_sidecar_for_extension(extension)">
|
||||
% if extension is None:
|
||||
<photoshop:SidecarForExtension></photoshop:SidecarForExtension>
|
||||
% else:
|
||||
<photoshop:SidecarForExtension>${extension}</photoshop:SidecarForExtension>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_description(desc)">
|
||||
% if desc is None:
|
||||
<dc:description></dc:description>
|
||||
@@ -71,29 +79,43 @@
|
||||
% 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)}
|
||||
${photoshop_sidecar_for_extension(extension)}
|
||||
${dc_description(description)}
|
||||
${dc_title(photo.title)}
|
||||
${dc_subject(photo.keywords + photo.persons)}
|
||||
${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(photo.persons)}
|
||||
${iptc_personinimage(persons)}
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
${dk_tagslist(photo.keywords)}
|
||||
${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>
|
||||
@@ -1,20 +1,26 @@
|
||||
import fnmatch
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from plistlib import load as plistload
|
||||
|
||||
import CoreFoundation
|
||||
import CoreServices
|
||||
import objc
|
||||
from Foundation import *
|
||||
|
||||
from osxphotos._applescript import AppleScript
|
||||
from ._constants import UNICODE_FORMAT
|
||||
from .fileutil import FileUtil
|
||||
|
||||
_DEBUG = False
|
||||
|
||||
@@ -114,40 +120,6 @@ def _dd_to_dms(dd):
|
||||
return int(deg_), int(min_), sec_
|
||||
|
||||
|
||||
def _copy_file(src, dest, norsrc=False):
|
||||
""" Copies a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy
|
||||
resource fork or extended attributes. May be useful on volumes that
|
||||
don't work with extended attributes (likely only certain SMB mounts)
|
||||
default is False
|
||||
Uses ditto to perform copy; will silently overwrite dest if it exists
|
||||
Raises exception if copy fails or either path is None """
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
|
||||
if norsrc:
|
||||
command = ["/usr/bin/ditto", "--norsrc", src, dest]
|
||||
else:
|
||||
command = ["/usr/bin/ditto", src, dest]
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
result = subprocess.run(command, check=True, stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.critical(
|
||||
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
|
||||
)
|
||||
raise e
|
||||
|
||||
return result.returncode
|
||||
|
||||
|
||||
def dd_to_dms_str(lat, lon):
|
||||
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
|
||||
""" lat: latitude in degrees """
|
||||
@@ -179,7 +151,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:
|
||||
@@ -196,16 +168,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():
|
||||
@@ -224,7 +190,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
|
||||
@@ -284,22 +250,28 @@ def list_photo_libraries():
|
||||
return lib_list
|
||||
|
||||
|
||||
def create_path_by_date(dest, dt):
|
||||
""" Creates a path in dest folder in form dest/YYYY/MM/DD/
|
||||
dest: valid path as str
|
||||
dt: datetime.timetuple() object
|
||||
Checks to see if path exists, if it does, do nothing and return path
|
||||
If path does not exist, creates it and returns path"""
|
||||
if not os.path.isdir(dest):
|
||||
raise FileNotFoundError(f"dest {dest} must be valid path")
|
||||
yyyy, mm, dd = dt[0:3]
|
||||
yyyy = str(yyyy).zfill(4)
|
||||
mm = str(mm).zfill(2)
|
||||
dd = str(dd).zfill(2)
|
||||
new_dest = os.path.join(dest, yyyy, mm, dd)
|
||||
if not os.path.isdir(new_dest):
|
||||
os.makedirs(new_dest)
|
||||
return new_dest
|
||||
def get_preferred_uti_extension(uti):
|
||||
""" get preferred extension for a UTI type
|
||||
uti: UTI str, e.g. 'public.jpeg'
|
||||
returns: preferred extension as str """
|
||||
|
||||
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
|
||||
|
||||
return CoreServices.UTTypeCopyPreferredTagWithClass(
|
||||
uti, CoreServices.kUTTagClassFilenameExtension
|
||||
)
|
||||
|
||||
|
||||
def findfiles(pattern, path_):
|
||||
"""Returns list of filenames from path_ matched by pattern
|
||||
shell pattern. Matching is case-insensitive.
|
||||
If 'path_' is invalid/doesn't exist, returns []."""
|
||||
if not os.path.isdir(path_):
|
||||
return []
|
||||
# See: https://gist.github.com/techtonik/5694830
|
||||
|
||||
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
|
||||
return [name for name in os.listdir(path_) if rule.match(name)]
|
||||
|
||||
|
||||
# TODO: this doesn't always work, still looking for a way to
|
||||
@@ -319,117 +291,6 @@ def create_path_by_date(dest, dt):
|
||||
# open_scpt.run()
|
||||
|
||||
|
||||
def _export_photo_uuid_applescript(
|
||||
uuid,
|
||||
dest,
|
||||
filestem=None,
|
||||
original=True,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
timeout=120,
|
||||
burst=False,
|
||||
):
|
||||
""" Export photo to dest path using applescript to control Photos
|
||||
If photo is a live photo, exports both the photo and associated .mov file
|
||||
uuid: UUID of photo to export
|
||||
dest: destination path to export to
|
||||
filestem: (string) if provided, exported filename will be named stem.ext
|
||||
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
||||
If not provided, file will be named with whatever name Photos uses
|
||||
If filestem.ext exists, it wil be overwritten
|
||||
original: (boolean) if True, export original image; default = True
|
||||
edited: (boolean) if True, export edited photo; default = False
|
||||
If photo not edited and edited=True, will still export the original image
|
||||
caller must verify image has been edited
|
||||
*Note*: must be called with either edited or original but not both,
|
||||
will raise error if called with both edited and original = True
|
||||
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
||||
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||
Returns: list of paths to exported file(s) or None if export failed
|
||||
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
||||
has not been edited. This is due to how Photos Applescript interface works.
|
||||
"""
|
||||
|
||||
# setup the applescript to do the export
|
||||
export_scpt = AppleScript(
|
||||
"""
|
||||
on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
|
||||
tell application "Photos"
|
||||
set thePath to thePath
|
||||
set theItem to media item id theUUID
|
||||
set theFilename to filename of theItem
|
||||
set itemList to {theItem}
|
||||
|
||||
if original then
|
||||
with timeout of theTimeOut seconds
|
||||
export itemList to POSIX file thePath with using originals
|
||||
end timeout
|
||||
end if
|
||||
|
||||
if edited then
|
||||
with timeout of theTimeOut seconds
|
||||
export itemList to POSIX file thePath
|
||||
end timeout
|
||||
end if
|
||||
|
||||
return theFilename
|
||||
end tell
|
||||
|
||||
end export_by_uuid
|
||||
"""
|
||||
)
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir:
|
||||
raise ValueError(f"dest {dest} must be a directory")
|
||||
|
||||
if not original ^ edited:
|
||||
raise ValueError(f"edited or original must be True but not both")
|
||||
|
||||
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
|
||||
# export original
|
||||
filename = None
|
||||
try:
|
||||
filename = export_scpt.call(
|
||||
"export_by_uuid", uuid, tmpdir.name, original, edited, timeout
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning("Error exporting uuid %s: %s" % (uuid, str(e)))
|
||||
return None
|
||||
|
||||
if filename is not None:
|
||||
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
|
||||
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
|
||||
# TemporaryDirectory will cleanup on return
|
||||
filename_stem = pathlib.Path(filename).stem
|
||||
files = glob.glob(os.path.join(tmpdir.name, "*"))
|
||||
exported_paths = []
|
||||
for fname in files:
|
||||
path = pathlib.Path(fname)
|
||||
if len(files) > 1 and not live_photo and path.suffix.lower() == ".mov":
|
||||
# it's the .mov part of live photo but not requested, so don't export
|
||||
logging.debug(f"Skipping live photo file {path}")
|
||||
continue
|
||||
if len(files) > 1 and burst and path.stem != filename_stem:
|
||||
# skip any burst photo that's not the one we asked for
|
||||
logging.debug(f"Skipping burst photo file {path}")
|
||||
continue
|
||||
if filestem:
|
||||
# rename the file based on filestem, keeping original extension
|
||||
dest_new = dest / f"{filestem}{path.suffix}"
|
||||
else:
|
||||
# use the name Photos provided
|
||||
dest_new = dest / path.name
|
||||
logging.debug(f"exporting {path} to dest_new: {dest_new}")
|
||||
_copy_file(str(path), str(dest_new))
|
||||
exported_paths.append(str(dest_new))
|
||||
return exported_paths
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _open_sql_file(dbname):
|
||||
""" opens sqlite file dbname in read-only mode
|
||||
returns tuple of (connection, cursor) """
|
||||
@@ -466,3 +327,40 @@ def _db_is_locked(dbname):
|
||||
locked = True
|
||||
|
||||
return locked
|
||||
|
||||
|
||||
# OSXPHOTOS_XATTR_UUID = "com.osxphotos.uuid"
|
||||
|
||||
# def get_uuid_for_file(filepath):
|
||||
# """ returns UUID associated with an exported file
|
||||
# filepath: path to exported photo
|
||||
# """
|
||||
# attr = xattr.xattr(filepath)
|
||||
# try:
|
||||
# uuid_bytes = attr[OSXPHOTOS_XATTR_UUID]
|
||||
# uuid_str = uuid_bytes.decode('utf-8')
|
||||
# except KeyError:
|
||||
# uuid_str = None
|
||||
# return uuid_str
|
||||
|
||||
# def set_uuid_for_file(filepath, uuid):
|
||||
# """ sets the UUID associated with an exported file
|
||||
# filepath: path to exported photo
|
||||
# uuid: uuid string for photo
|
||||
# """
|
||||
# if not os.path.exists(filepath):
|
||||
# raise FileNotFoundError(f"Missing file: {filepath}")
|
||||
|
||||
# attr = xattr.xattr(filepath)
|
||||
# uuid_bytes = bytes(uuid, 'utf-8')
|
||||
# attr.set(OSXPHOTOS_XATTR_UUID, uuid_bytes)
|
||||
|
||||
|
||||
def normalize_unicode(value):
|
||||
""" normalize unicode data """
|
||||
if value is not None:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("value must be str")
|
||||
return unicodedata.normalize(UNICODE_FORMAT, value)
|
||||
else:
|
||||
return None
|
||||
|
||||
290
requirements.txt
@@ -1,19 +1,38 @@
|
||||
aiohttp==4.0.0a1
|
||||
altgraph==0.17
|
||||
ansimarkup==1.4.0
|
||||
appdirs==1.4.3
|
||||
appnope==0.1.0
|
||||
astroid==2.2.5
|
||||
async-timeout==3.0.1
|
||||
atomicwrites==1.3.0
|
||||
attrs==19.1.0
|
||||
backcall==0.1.0
|
||||
better-exceptions-fork==0.2.1.post6
|
||||
# bpylist2==2.0.3;python_version<"3.8"
|
||||
https://github.com/RhetTbull/bpylist/releases/download/v2.0.3/bpylist2-2.0.3.tar.gz#egg=bpylist2;python_version<"3.8"
|
||||
bpylist2==3.0.0;python_version>="3.8"
|
||||
certifi==2019.3.9
|
||||
black==19.10b0
|
||||
bleach==3.1.4
|
||||
bpylist2==3.0.2
|
||||
certifi==2020.4.5.1
|
||||
cffi==1.14.0
|
||||
chardet==3.0.4
|
||||
Click==7.0
|
||||
colorama==0.4.1
|
||||
coverage==4.5.4
|
||||
importlib-metadata>=0.18
|
||||
decorator==4.4.2
|
||||
distlib==0.3.1
|
||||
docutils==0.16
|
||||
entrypoints==0.3
|
||||
filelock==3.0.12
|
||||
idna==2.9
|
||||
importlib-metadata==1.6.0
|
||||
ipykernel==5.1.4
|
||||
ipython==7.13.0
|
||||
ipython-genutils==0.2.0
|
||||
isort==4.3.20
|
||||
jedi==0.16.0
|
||||
jupyter-client==6.1.2
|
||||
jupyter-core==4.6.3
|
||||
keyring==21.2.0
|
||||
lazy-object-proxy==1.4.1
|
||||
loguru==0.2.5
|
||||
macholib==1.14
|
||||
@@ -22,137 +41,166 @@ MarkupSafe==1.1.1
|
||||
mccabe==0.6.1
|
||||
modulegraph==0.18
|
||||
more-itertools==7.2.0
|
||||
multidict==4.7.6
|
||||
packaging==19.0
|
||||
parso==0.6.2
|
||||
pathspec==0.7.0
|
||||
pathvalidate==2.2.1
|
||||
pexpect==4.8.0
|
||||
pickleshare==0.7.5
|
||||
Pillow==7.2.0
|
||||
pkginfo==1.5.0.1
|
||||
pluggy==0.12.0
|
||||
prompt-toolkit==3.0.4
|
||||
psutil==5.7.0
|
||||
ptyprocess==0.6.0
|
||||
py==1.8.0
|
||||
py2app==0.21
|
||||
Pygments==2.4.2
|
||||
pycparser==2.20
|
||||
pyfiglet==0.8.post1
|
||||
Pygments==2.6.1
|
||||
PyInstaller==3.6
|
||||
pyinstaller-setuptools==2019.3
|
||||
pylint==2.3.1
|
||||
pyobjc==6.0.1
|
||||
pyobjc-core==6.0.1
|
||||
pyobjc-framework-Accounts==6.0.1
|
||||
pyobjc-framework-AddressBook==6.0.1
|
||||
pyobjc-framework-AdSupport==6.0.1
|
||||
pyobjc-framework-AppleScriptKit==6.0.1
|
||||
pyobjc-framework-AppleScriptObjC==6.0.1
|
||||
pyobjc-framework-ApplicationServices==6.0.1
|
||||
pyobjc-framework-AuthenticationServices==6.0.1
|
||||
pyobjc-framework-Automator==6.0.1
|
||||
pyobjc-framework-AVFoundation==6.0.1
|
||||
pyobjc-framework-AVKit==6.0.1
|
||||
pyobjc-framework-BusinessChat==6.0.1
|
||||
pyobjc-framework-CalendarStore==6.0.1
|
||||
pyobjc-framework-CFNetwork==6.0.1
|
||||
pyobjc-framework-CloudKit==6.0.1
|
||||
pyobjc-framework-Cocoa==6.0.1
|
||||
pyobjc-framework-Collaboration==6.0.1
|
||||
pyobjc-framework-ColorSync==6.0.1
|
||||
pyobjc-framework-Contacts==6.0.1
|
||||
pyobjc-framework-ContactsUI==6.0.1
|
||||
pyobjc-framework-CoreAudio==6.0.1
|
||||
pyobjc-framework-CoreAudioKit==6.0.1
|
||||
pyobjc-framework-CoreBluetooth==6.0.1
|
||||
pyobjc-framework-CoreData==6.0.1
|
||||
pyobjc-framework-CoreHaptics==6.0.1
|
||||
pyobjc-framework-CoreLocation==6.0.1
|
||||
pyobjc-framework-CoreMedia==6.0.1
|
||||
pyobjc-framework-CoreMediaIO==6.0.1
|
||||
pyobjc-framework-CoreML==6.0.1
|
||||
pyobjc-framework-CoreMotion==6.0.1
|
||||
pyobjc-framework-CoreServices==6.0.1
|
||||
pyobjc-framework-CoreSpotlight==6.0.1
|
||||
pyobjc-framework-CoreText==6.0.1
|
||||
pyobjc-framework-CoreWLAN==6.0.1
|
||||
pyobjc-framework-CryptoTokenKit==6.0.1
|
||||
pyobjc-framework-DeviceCheck==6.0.1
|
||||
pyobjc-framework-DictionaryServices==6.0.1
|
||||
pyobjc-framework-DiscRecording==6.0.1
|
||||
pyobjc-framework-DiscRecordingUI==6.0.1
|
||||
pyobjc-framework-DiskArbitration==6.0.1
|
||||
pyobjc-framework-DVDPlayback==6.0.1
|
||||
pyobjc-framework-EventKit==6.0.1
|
||||
pyobjc-framework-ExceptionHandling==6.0.1
|
||||
pyobjc-framework-ExecutionPolicy==6.0.1
|
||||
pyobjc-framework-ExternalAccessory==6.0.1
|
||||
pyobjc-framework-FileProvider==6.0.1
|
||||
pyobjc-framework-FileProviderUI==6.0.1
|
||||
pyobjc-framework-FinderSync==6.0.1
|
||||
pyobjc-framework-FSEvents==6.0.1
|
||||
pyobjc-framework-GameCenter==6.0.1
|
||||
pyobjc-framework-GameController==6.0.1
|
||||
pyobjc-framework-GameKit==6.0.1
|
||||
pyobjc-framework-GameplayKit==6.0.1
|
||||
pyobjc-framework-ImageCaptureCore==6.0.1
|
||||
pyobjc-framework-IMServicePlugIn==6.0.1
|
||||
pyobjc-framework-InputMethodKit==6.0.1
|
||||
pyobjc-framework-InstallerPlugins==6.0.1
|
||||
pyobjc-framework-InstantMessage==6.0.1
|
||||
pyobjc-framework-Intents==6.0.1
|
||||
pyobjc-framework-IOSurface==6.0.1
|
||||
pyobjc-framework-iTunesLibrary==6.0.1
|
||||
pyobjc-framework-LatentSemanticMapping==6.0.1
|
||||
pyobjc-framework-LaunchServices==6.0.1
|
||||
pyobjc-framework-libdispatch==6.0.1
|
||||
pyobjc-framework-LinkPresentation==6.0.1
|
||||
pyobjc-framework-LocalAuthentication==6.0.1
|
||||
pyobjc-framework-MapKit==6.0.1
|
||||
pyobjc-framework-MediaAccessibility==6.0.1
|
||||
pyobjc-framework-MediaLibrary==6.0.1
|
||||
pyobjc-framework-MediaPlayer==6.0.1
|
||||
pyobjc-framework-MediaToolbox==6.0.1
|
||||
pyobjc-framework-MetalKit==6.0.1
|
||||
pyobjc-framework-ModelIO==6.0.1
|
||||
pyobjc-framework-MultipeerConnectivity==6.0.1
|
||||
pyobjc-framework-NaturalLanguage==6.0.1
|
||||
pyobjc-framework-NetFS==6.0.1
|
||||
pyobjc-framework-Network==6.0.1
|
||||
pyobjc-framework-NetworkExtension==6.0.1
|
||||
pyobjc-framework-NotificationCenter==6.0.1
|
||||
pyobjc-framework-OpenDirectory==6.0.1
|
||||
pyobjc-framework-OSAKit==6.0.1
|
||||
pyobjc-framework-OSLog==6.0.1
|
||||
pyobjc-framework-PencilKit==6.0.1
|
||||
pyobjc-framework-Photos==6.0.1
|
||||
pyobjc-framework-PhotosUI==6.0.1
|
||||
pyobjc-framework-PreferencePanes==6.0.1
|
||||
pyobjc-framework-PubSub==6.0.1
|
||||
pyobjc-framework-PushKit==6.0.1
|
||||
pyobjc==6.2.2
|
||||
pyobjc-core==6.2.2
|
||||
pyobjc-framework-Accounts==6.2.2
|
||||
pyobjc-framework-AddressBook==6.2.2
|
||||
pyobjc-framework-AdSupport==6.2.2
|
||||
pyobjc-framework-AppleScriptKit==6.2.2
|
||||
pyobjc-framework-AppleScriptObjC==6.2.2
|
||||
pyobjc-framework-ApplicationServices==6.2.2
|
||||
pyobjc-framework-AuthenticationServices==6.2.2
|
||||
pyobjc-framework-AutomaticAssessmentConfiguration==6.2.2
|
||||
pyobjc-framework-Automator==6.2.2
|
||||
pyobjc-framework-AVFoundation==6.2.2
|
||||
pyobjc-framework-AVKit==6.2.2
|
||||
pyobjc-framework-BusinessChat==6.2.2
|
||||
pyobjc-framework-CalendarStore==6.2.2
|
||||
pyobjc-framework-CFNetwork==6.2.2
|
||||
pyobjc-framework-CloudKit==6.2.2
|
||||
pyobjc-framework-Cocoa==6.2.2
|
||||
pyobjc-framework-Collaboration==6.2.2
|
||||
pyobjc-framework-ColorSync==6.2.2
|
||||
pyobjc-framework-Contacts==6.2.2
|
||||
pyobjc-framework-ContactsUI==6.2.2
|
||||
pyobjc-framework-CoreAudio==6.2.2
|
||||
pyobjc-framework-CoreAudioKit==6.2.2
|
||||
pyobjc-framework-CoreBluetooth==6.2.2
|
||||
pyobjc-framework-CoreData==6.2.2
|
||||
pyobjc-framework-CoreHaptics==6.2.2
|
||||
pyobjc-framework-CoreLocation==6.2.2
|
||||
pyobjc-framework-CoreMedia==6.2.2
|
||||
pyobjc-framework-CoreMediaIO==6.2.2
|
||||
pyobjc-framework-CoreML==6.2.2
|
||||
pyobjc-framework-CoreMotion==6.2.2
|
||||
pyobjc-framework-CoreServices==6.2.2
|
||||
pyobjc-framework-CoreSpotlight==6.2.2
|
||||
pyobjc-framework-CoreText==6.2.2
|
||||
pyobjc-framework-CoreWLAN==6.2.2
|
||||
pyobjc-framework-CryptoTokenKit==6.2.2
|
||||
pyobjc-framework-DeviceCheck==6.2.2
|
||||
pyobjc-framework-DictionaryServices==6.2.2
|
||||
pyobjc-framework-DiscRecording==6.2.2
|
||||
pyobjc-framework-DiscRecordingUI==6.2.2
|
||||
pyobjc-framework-DiskArbitration==6.2.2
|
||||
pyobjc-framework-DVDPlayback==6.2.2
|
||||
pyobjc-framework-EventKit==6.2.2
|
||||
pyobjc-framework-ExceptionHandling==6.2.2
|
||||
pyobjc-framework-ExecutionPolicy==6.2.2
|
||||
pyobjc-framework-ExternalAccessory==6.2.2
|
||||
pyobjc-framework-FileProvider==6.2.2
|
||||
pyobjc-framework-FileProviderUI==6.2.2
|
||||
pyobjc-framework-FinderSync==6.2.2
|
||||
pyobjc-framework-FSEvents==6.2.2
|
||||
pyobjc-framework-GameCenter==6.2.2
|
||||
pyobjc-framework-GameController==6.2.2
|
||||
pyobjc-framework-GameKit==6.2.2
|
||||
pyobjc-framework-GameplayKit==6.2.2
|
||||
pyobjc-framework-ImageCaptureCore==6.2.2
|
||||
pyobjc-framework-IMServicePlugIn==6.2.2
|
||||
pyobjc-framework-InputMethodKit==6.2.2
|
||||
pyobjc-framework-InstallerPlugins==6.2.2
|
||||
pyobjc-framework-InstantMessage==6.2.2
|
||||
pyobjc-framework-Intents==6.2.2
|
||||
pyobjc-framework-IOSurface==6.2.2
|
||||
pyobjc-framework-iTunesLibrary==6.2.2
|
||||
pyobjc-framework-LatentSemanticMapping==6.2.2
|
||||
pyobjc-framework-LaunchServices==6.2.2
|
||||
pyobjc-framework-libdispatch==6.2.2
|
||||
pyobjc-framework-LinkPresentation==6.2.2
|
||||
pyobjc-framework-LocalAuthentication==6.2.2
|
||||
pyobjc-framework-MapKit==6.2.2
|
||||
pyobjc-framework-MediaAccessibility==6.2.2
|
||||
pyobjc-framework-MediaLibrary==6.2.2
|
||||
pyobjc-framework-MediaPlayer==6.2.2
|
||||
pyobjc-framework-MediaToolbox==6.2.2
|
||||
pyobjc-framework-Metal==6.2.2
|
||||
pyobjc-framework-MetalKit==6.2.2
|
||||
pyobjc-framework-ModelIO==6.2.2
|
||||
pyobjc-framework-MultipeerConnectivity==6.2.2
|
||||
pyobjc-framework-NaturalLanguage==6.2.2
|
||||
pyobjc-framework-NetFS==6.2.2
|
||||
pyobjc-framework-Network==6.2.2
|
||||
pyobjc-framework-NetworkExtension==6.2.2
|
||||
pyobjc-framework-NotificationCenter==6.2.2
|
||||
pyobjc-framework-OpenDirectory==6.2.2
|
||||
pyobjc-framework-OSAKit==6.2.2
|
||||
pyobjc-framework-OSLog==6.2.2
|
||||
pyobjc-framework-PencilKit==6.2.2
|
||||
pyobjc-framework-Photos==6.2.2
|
||||
pyobjc-framework-PhotosUI==6.2.2
|
||||
pyobjc-framework-PreferencePanes==6.2.2
|
||||
pyobjc-framework-PubSub==6.2
|
||||
pyobjc-framework-PushKit==6.2.2
|
||||
pyobjc-framework-QTKit==6.0.1
|
||||
pyobjc-framework-Quartz==6.0.1
|
||||
pyobjc-framework-QuickLookThumbnailing==6.0.1
|
||||
pyobjc-framework-SafariServices==6.0.1
|
||||
pyobjc-framework-SceneKit==6.0.1
|
||||
pyobjc-framework-ScreenSaver==6.0.1
|
||||
pyobjc-framework-ScriptingBridge==6.0.1
|
||||
pyobjc-framework-SearchKit==6.0.1
|
||||
pyobjc-framework-Security==6.0.1
|
||||
pyobjc-framework-SecurityFoundation==6.0.1
|
||||
pyobjc-framework-SecurityInterface==6.0.1
|
||||
pyobjc-framework-ServiceManagement==6.0.1
|
||||
pyobjc-framework-Social==6.0.1
|
||||
pyobjc-framework-SoundAnalysis==6.0.1
|
||||
pyobjc-framework-Speech==6.0.1
|
||||
pyobjc-framework-SpriteKit==6.0.1
|
||||
pyobjc-framework-StoreKit==6.0.1
|
||||
pyobjc-framework-SyncServices==6.0.1
|
||||
pyobjc-framework-SystemConfiguration==6.0.1
|
||||
pyobjc-framework-SystemExtensions==6.0.1
|
||||
pyobjc-framework-UserNotifications==6.0.1
|
||||
pyobjc-framework-VideoSubscriberAccount==6.0.1
|
||||
pyobjc-framework-VideoToolbox==6.0.1
|
||||
pyobjc-framework-Vision==6.0.1
|
||||
pyobjc-framework-WebKit==6.0.1
|
||||
pyobjc-framework-Quartz==6.2.2
|
||||
pyobjc-framework-QuickLookThumbnailing==6.2.2
|
||||
pyobjc-framework-SafariServices==6.2.2
|
||||
pyobjc-framework-SceneKit==6.2.2
|
||||
pyobjc-framework-ScreenSaver==6.2.2
|
||||
pyobjc-framework-ScriptingBridge==6.2.2
|
||||
pyobjc-framework-SearchKit==6.2.2
|
||||
pyobjc-framework-Security==6.2.2
|
||||
pyobjc-framework-SecurityFoundation==6.2.2
|
||||
pyobjc-framework-SecurityInterface==6.2.2
|
||||
pyobjc-framework-ServiceManagement==6.2.2
|
||||
pyobjc-framework-Social==6.2.2
|
||||
pyobjc-framework-SoundAnalysis==6.2.2
|
||||
pyobjc-framework-Speech==6.2.2
|
||||
pyobjc-framework-SpriteKit==6.2.2
|
||||
pyobjc-framework-StoreKit==6.2.2
|
||||
pyobjc-framework-SyncServices==6.2.2
|
||||
pyobjc-framework-SystemConfiguration==6.2.2
|
||||
pyobjc-framework-SystemExtensions==6.2.2
|
||||
pyobjc-framework-UserNotifications==6.2.2
|
||||
pyobjc-framework-VideoSubscriberAccount==6.2.2
|
||||
pyobjc-framework-VideoToolbox==6.2.2
|
||||
pyobjc-framework-Vision==6.2.2
|
||||
pyobjc-framework-WebKit==6.2.2
|
||||
pyparsing==2.4.1.1
|
||||
python-dateutil==2.8.1
|
||||
PyYAML==5.1.2
|
||||
pyzmq==18.1.1
|
||||
readme-renderer==25.0
|
||||
regex==2020.2.20
|
||||
six==1.12.0
|
||||
requests==2.23.0
|
||||
requests-toolbelt==0.9.1
|
||||
six==1.14.0
|
||||
termcolor==1.1.0
|
||||
toml==0.10.0
|
||||
tornado==6.0.4
|
||||
tox==3.19.0
|
||||
tox-conda==0.2.1
|
||||
tqdm==4.45.0
|
||||
traitlets==4.3.3
|
||||
twine==3.1.1
|
||||
typed-ast==1.4.1
|
||||
wcwidth==0.1.7
|
||||
typing-extensions==3.7.4.2
|
||||
urllib3==1.25.9
|
||||
virtualenv==20.0.30
|
||||
wcwidth==0.1.9
|
||||
webencodings==0.5.1
|
||||
wrapt==1.11.1
|
||||
yarl==1.4.2
|
||||
zipp==0.5.2
|
||||
|
||||
27
setup.py
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# setup.py script for osxphotos
|
||||
#
|
||||
# Copyright (c) 2019 Rhet Turnbull, rturnbull+git@gmail.com
|
||||
# Copyright (c) 2019, 2020 Rhet Turnbull, rturnbull+git@gmail.com
|
||||
# All rights reserved.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person
|
||||
@@ -27,23 +27,32 @@
|
||||
# SOFTWARE.
|
||||
|
||||
import os
|
||||
import platform
|
||||
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
this_directory = os.path.abspath(os.path.dirname(__file__))
|
||||
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
|
||||
long_description = f.read()
|
||||
# python version as 2-digit float (e.g. 3.6)
|
||||
py_ver = float(".".join(platform.python_version_tuple()[:2]))
|
||||
|
||||
# holds config info read from disk
|
||||
about = {}
|
||||
this_directory = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
# get version info from _version
|
||||
with open(
|
||||
os.path.join(this_directory, "osxphotos", "_version.py"), mode="r", encoding="utf-8"
|
||||
) as f:
|
||||
exec(f.read(), about)
|
||||
|
||||
# read README.md into long_description
|
||||
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
|
||||
about["long_description"] = f.read()
|
||||
|
||||
setup(
|
||||
name="osxphotos",
|
||||
version=about["__version__"],
|
||||
description="Manipulate (read-only) Apple's Photos app library on Mac OS X",
|
||||
long_description=long_description,
|
||||
long_description=about["long_description"],
|
||||
long_description_content_type="text/markdown",
|
||||
author="Rhet Turnbull",
|
||||
author_email="rturnbull+git@gmail.com",
|
||||
@@ -58,17 +67,17 @@ setup(
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: MacOS :: MacOS X",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
],
|
||||
install_requires=[
|
||||
"pyobjc>=6.0.1",
|
||||
"pyobjc>=6.2.2",
|
||||
"Click>=7",
|
||||
"PyYAML>=5.1.2",
|
||||
"Mako>=1.1.1",
|
||||
"bpylist2==2.0.3;python_version<'3.8'",
|
||||
"bpylist2==3.0.0;python_version>='3.8'",
|
||||
"bpylist2==3.0.2",
|
||||
"pathvalidate==2.2.1",
|
||||
"dataclasses==0.7;python_version<'3.7'",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
# Tests for osxphotos #
|
||||
|
||||
## Running Tests ##
|
||||
Tests require pytest:
|
||||
Tests require pytest and pytest-mock:
|
||||
`pip install pytest`
|
||||
`pip install pytest-mock`
|
||||
|
||||
To run the tests, do the following from the main source folder:
|
||||
`python -m pytest tests/`
|
||||
|
||||
Running the tests this way allows the library to be tested without installing it.
|
||||
|
||||
## Attribution ##
|
||||
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com). All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
|
||||
## Skipped Tests ##
|
||||
A few tests will look for certain environment variables to determine if they should run.
|
||||
|
||||
Images used from:
|
||||
Some of the export tests rely on photos in my local library and will look for `OSXPHOTOS_TEST_EXPORT=1` to determine if they should run.
|
||||
|
||||
One test for locale does not run on GitHub's automated workflow and will look for `OSXPHOTOS_TEST_LOCALE=1` to determine if it should be run. If you want to run this test, set the environment variable.
|
||||
|
||||
## Test Photo Libraries
|
||||
**Important**: The test code uses several test photo libraries created on various version of MacOS. If you need to inspect one of these or modify one for a test, make a copy of the library (for example, copy it to your ~/Pictures folder) then open the copy in Photos. Once done, copy the revised library back to the tests/ folder. If you do not do this, the Photos background process photoanalysisd will forever try to process the library resulting in updates to the database which will cause git to see changes to the file you didn't intend. I'm not aware of any way to disassociate photoanalysisd from the library once you've opened it in Photos.
|
||||
|
||||
## Attribution ##
|
||||
These tests utilize a test Photos library. The test library is populated with photos from [flickr](https://www.flickr.com) and from my own photo library. All images used are licensed under Creative Commons 2.0 Attribution [license](https://creativecommons.org/licenses/by/2.0/).
|
||||
|
||||
Flickr images used from:
|
||||
- [Jeff Hitchcock](https://www.flickr.com/photos/arbron/48353451872/)
|
||||
- [Carlos Montesdeoca](https://www.flickr.com/photos/carlosmontesdeocastudio)
|
||||
- [Rydale Clothing](https://www.flickr.com/photos/rydaleclothing)
|
||||
- [Marco Verch](https://www.flickr.com/photos/30478819@N08/48228222317/)
|
||||
- [K M](https://www.flickr.com/photos/153387643@N08/49334338022/)
|
||||
|
||||
- [Shelby Mash](https://www.flickr.com/photos/shelbzyleigh/3809603052)
|
||||
- [Rory MacLeod](https://www.flickr.com/photos/macrj/6969547134)
|
||||
- [Md. Al Amin](https://www.flickr.com/photos/alamin_bd/45207044465)
|
||||
- [Fatlum Haliti](https://www.flickr.com/photos/lumlumi/363449752)
|
||||
- [Benny Mazur](https://www.flickr.com/photos/benimoto/399012465)
|
||||
- [Sara Cooper PR](https://www.flickr.com/photos/saracooperpr/6422472677)
|
||||
- [herval](https://www.flickr.com/photos/herval/2403994289)
|
||||
- [Vox Efx](https://www.flickr.com/photos/vox_efx/141137669)
|
||||
- [Bill Strain](https://www.flickr.com/photos/billstrain/5117042252)
|
||||
- [Guilherme Yagui](https://www.flickr.com/photos/yagui7/15895161088/)
|
||||
- [Deborah Austin](https://www.flickr.com/photos/littledebbie11/8703591799/)
|
||||
- [We Are Social](https://www.flickr.com/photos/wearesocial/23309711462/)
|
||||
- [cloud.shepherd](https://www.flickr.com/photos/exnucboy1/31017877125)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 2.8 MiB |
@@ -5,7 +5,7 @@
|
||||
<key>LithiumMessageTracer</key>
|
||||
<dict>
|
||||
<key>LastReportedDate</key>
|
||||
<date>2019-12-08T16:44:38Z</date>
|
||||
<date>2020-04-17T18:39:50Z</date>
|
||||
</dict>
|
||||
<key>PXPeopleScreenUnlocked</key>
|
||||
<true/>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-01-22T02:10:26Z</date>
|
||||
<date>2020-04-17T18:40:46Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-01-22T02:10:27Z</date>
|
||||
<date>2020-04-17T18:39:51Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
After Width: | Height: | Size: 3.1 MiB |
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-01-19T17:29:28Z</date>
|
||||
<date>2020-04-17T18:39:52Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>414</integer>
|
||||
<integer>502</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 147 KiB |
@@ -9,7 +9,7 @@
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>414</integer>
|
||||
<integer>502</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>obfeGcvoT1auxoh2Tu86OQ</string>
|
||||
<string>TopLevelAlbums</string>
|
||||
<string>TopLevelSlideshows</string>
|
||||
<string>MBS8+gBrQCWQxmcav+C8HQ</string>
|
||||
<string>cHwwVoUiQ8a2nZNXgVsnCA</string>
|
||||
</array>
|
||||
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
||||
<dict>
|
||||
@@ -23,11 +26,11 @@
|
||||
<key>key</key>
|
||||
<integer>1</integer>
|
||||
<key>lastKnownDisplayName</key>
|
||||
<string>September 28, 2018</string>
|
||||
<string>Pumpkin Farm (1)</string>
|
||||
<key>type</key>
|
||||
<string>album</string>
|
||||
<key>uuid</key>
|
||||
<string>+Ep8CrNRRhea9eVA618FMg</string>
|
||||
<string>AU8Gp8bwRlOvngZFgwXBdg</string>
|
||||
</dict>
|
||||
<key>lastKnownItemCounts</key>
|
||||
<dict>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2019-07-28T01:23:52Z</date>
|
||||
<date>2020-04-30T14:09:38Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2019-07-28T01:23:52Z</date>
|
||||
<date>2020-05-01T04:27:48Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ProcessedInQuiescentState</key>
|
||||
<true/>
|
||||
<key>SuggestedMeIdentifier</key>
|
||||
<string></string>
|
||||
<key>Version</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>LithiumMessageTracer</key>
|
||||
<dict>
|
||||
<key>LastReportedDate</key>
|
||||
<date>2019-07-27T12:01:15Z</date>
|
||||
<date>2020-05-01T03:34:50Z</date>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2019-07-26T20:15:18Z</date>
|
||||
<date>2020-04-30T12:51:41Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>615</integer>
|
||||
<integer>670</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>615</integer>
|
||||
<integer>670</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>BEA5F0E8-BA6B-4462-8F73-3E53BBE4C943</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
@@ -24,7 +24,7 @@
|
||||
<key>SnapshotCompletedDate</key>
|
||||
<date>2019-07-26T20:15:17Z</date>
|
||||
<key>SnapshotLastValidated</key>
|
||||
<date>2019-07-27T12:01:15Z</date>
|
||||
<date>2020-05-01T03:34:50Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
|
||||
|
After Width: | Height: | Size: 2.1 MiB |