Compare commits
383 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9515736019 | ||
|
|
42a6373f8d | ||
|
|
08725fd27f | ||
|
|
62d54cc0be | ||
|
|
6883fec2b2 | ||
|
|
228dfcdc67 | ||
|
|
c939df7171 | ||
|
|
3d21dadf41 | ||
|
|
432da7f139 | ||
|
|
aa2cf826c7 | ||
|
|
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 | ||
|
|
3e5062684a | ||
|
|
626e460aab | ||
|
|
1820715849 | ||
|
|
a6ca3f453c | ||
|
|
ddaa66d19e | ||
|
|
6073acc9d3 | ||
|
|
bae0283441 | ||
|
|
507c4a3740 | ||
|
|
6a898886dd | ||
|
|
01cd7fed6d | ||
|
|
e8273c9752 | ||
|
|
fd5e748dca | ||
|
|
c02953ef5f | ||
|
|
daea30f162 | ||
|
|
be2e16769d | ||
|
|
b0456dc8e6 | ||
|
|
c8bd8ea2f3 | ||
|
|
67a9a9e21b | ||
|
|
427c4c0bc4 | ||
|
|
f0d200435a | ||
|
|
49de3ecd2e | ||
|
|
c06dd4233f | ||
|
|
fd638427d0 | ||
|
|
6fb8fe8142 | ||
|
|
69cc6ce680 | ||
|
|
dfc31ff15f | ||
|
|
707544752e | ||
|
|
564a5073f1 | ||
|
|
d769dde358 | ||
|
|
d066435e3d | ||
|
|
8f0307fc24 | ||
|
|
908fead8a2 | ||
|
|
072e894e56 | ||
|
|
47e57ee98e | ||
|
|
e90d9c6e11 | ||
|
|
2feb0999b3 | ||
|
|
d26ea0dccc | ||
|
|
aeae1e0b8a | ||
|
|
128a35d6c0 | ||
|
|
57d9163090 | ||
|
|
a236ed42c1 | ||
|
|
ad58b03f2d | ||
|
|
066215621d | ||
|
|
7f0558e08b | ||
|
|
4441d071b3 | ||
|
|
9da7ad6dcc | ||
|
|
91f71df07d | ||
|
|
9e314bfaf4 | ||
|
|
0948b24821 | ||
|
|
4b951826db | ||
|
|
cda5f44693 | ||
|
|
960487f296 | ||
|
|
6ab1511b4f | ||
|
|
b8da9765b8 | ||
|
|
21547a8eaa | ||
|
|
23b26ed130 | ||
|
|
92e5bdd2e9 | ||
|
|
a723881dd3 | ||
|
|
b338b34d50 | ||
|
|
816b98e617 | ||
|
|
39ffef502c | ||
|
|
1e08a7449e | ||
|
|
0940f039d3 | ||
|
|
c11afbaa6e | ||
|
|
940fc33f11 | ||
|
|
8542e1a97f | ||
|
|
dd20b8d8ac | ||
|
|
765a3d27c5 | ||
|
|
b68f4c2b8b | ||
|
|
cc9220e076 | ||
|
|
e99391a68e | ||
|
|
783e097da3 | ||
|
|
279ab36929 | ||
|
|
1f13ba837f | ||
|
|
dc87194eec | ||
|
|
d32774f495 | ||
|
|
7da02991cf | ||
|
|
6f413c64d7 | ||
|
|
2d7d0b86e0 | ||
|
|
acb6b9e72f | ||
|
|
f1ade92e98 | ||
|
|
a27ce33473 | ||
|
|
2b7d84a4d1 | ||
|
|
92b405a166 | ||
|
|
15d7ad538d | ||
|
|
1f8fd6e929 | ||
|
|
08a9793651 | ||
|
|
2c8fc9789f | ||
|
|
dbededcd0e | ||
|
|
ef799610ae | ||
|
|
8dea41961b | ||
|
|
5799afbdc1 | ||
|
|
9a0fc0db3e | ||
|
|
549170fa36 | ||
|
|
dede640ef3 | ||
|
|
2b3491bdc4 |
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]
|
||||
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/
|
||||
|
||||
642
CHANGELOG.md
@@ -4,6 +4,636 @@ 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.3](https://github.com/RhetTbull/osxphotos/compare/v0.34.2...v0.34.3)
|
||||
|
||||
> 29 September 2020
|
||||
|
||||
- Update exiftool.py to preserve file modification time, thanks to @hhoeck [`#223`](https://github.com/RhetTbull/osxphotos/pull/223)
|
||||
- Added tests for 10.15.6 [`432da7f`](https://github.com/RhetTbull/osxphotos/commit/432da7f139a5e4b37eeb358f4ede45314407f8e5)
|
||||
- Fixed bug related to issue #222 [`c939df7`](https://github.com/RhetTbull/osxphotos/commit/c939df717159e8b97955c0b267327cd56a9ed56c)
|
||||
- Version bump for bug fix [`62d54cc`](https://github.com/RhetTbull/osxphotos/commit/62d54cc0beabd0141545608184d4b2c658eedf0f)
|
||||
- Update README.md [`6883fec`](https://github.com/RhetTbull/osxphotos/commit/6883fec2b2236d892b88327e1b4e9da1237f7dea)
|
||||
- Update exiftool.py [`3d21dad`](https://github.com/RhetTbull/osxphotos/commit/3d21dadf4102e9101e48a0c6f739a544f7f9d9de)
|
||||
|
||||
#### [v0.34.2](https://github.com/RhetTbull/osxphotos/compare/v0.34.1...v0.34.2)
|
||||
|
||||
> 14 September 2020
|
||||
|
||||
- Partial fix for issue #213 [`459d91d`](https://github.com/RhetTbull/osxphotos/commit/459d91d7b11dbd4b0564906c1689b60dc5b64642)
|
||||
|
||||
#### [v0.34.1](https://github.com/RhetTbull/osxphotos/compare/v0.34.0...v0.34.1)
|
||||
|
||||
> 13 September 2020
|
||||
|
||||
- Fixed exception handling in export [`eb00ffd`](https://github.com/RhetTbull/osxphotos/commit/eb00ffd73737ef4832229e4e6fd8dc4ccb0b8539)
|
||||
- Updated README.md [`a1776fa`](https://github.com/RhetTbull/osxphotos/commit/a1776fa14850275ad6b02ece80bbe8ce908fa836)
|
||||
|
||||
#### [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
|
||||
|
||||
- Added --no-extended-attributes option to CLI, closes #85 [`#85`](https://github.com/RhetTbull/osxphotos/issues/85)
|
||||
- Fixed CLI help for invalid topic, closes #76 [`#76`](https://github.com/RhetTbull/osxphotos/issues/76)
|
||||
- Updated test library [`bae0283`](https://github.com/RhetTbull/osxphotos/commit/bae0283441f04d71aa78dbd1cf014f376ef1f91a)
|
||||
|
||||
#### [v0.25.0](https://github.com/RhetTbull/osxphotos/compare/v0.24.2...v0.25.0)
|
||||
|
||||
> 4 April 2020
|
||||
|
||||
- Added places, --place, --no-place to CLI, closes #87, #88 [`#87`](https://github.com/RhetTbull/osxphotos/issues/87)
|
||||
- Updated render_filepath_template to support multiple values [`6a89888`](https://github.com/RhetTbull/osxphotos/commit/6a898886ddadc9d5bc9dbad6ee7365270dd0a26d)
|
||||
- Added {album}, {keyword}, and {person} to template system [`507c4a3`](https://github.com/RhetTbull/osxphotos/commit/507c4a374014f999ca19789bce0df0c14332e021)
|
||||
- Added places command to CLI [`fd5e748`](https://github.com/RhetTbull/osxphotos/commit/fd5e748dca759ea1c3a7329d447f363afe8418b7)
|
||||
- Updated export example [`01cd7fe`](https://github.com/RhetTbull/osxphotos/commit/01cd7fed6d7fc0c61c171a05319c211eb0a9f7c1)
|
||||
- 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)
|
||||
|
||||
> 28 March 2020
|
||||
|
||||
- added {place.country_code} to template system [`be2e167`](https://github.com/RhetTbull/osxphotos/commit/be2e16769d5d2c75af6d7792f1311f5a65c3bc67)
|
||||
|
||||
#### [v0.24.1](https://github.com/RhetTbull/osxphotos/compare/v0.23.4...v0.24.1)
|
||||
|
||||
> 28 March 2020
|
||||
|
||||
- Added detailed place data in PlaceInfo.names [`c06dd42`](https://github.com/RhetTbull/osxphotos/commit/c06dd4233f917f068c087f5604013d371b0a826a)
|
||||
- Template system now supports default values [`67a9a9e`](https://github.com/RhetTbull/osxphotos/commit/67a9a9e21bd05d01a3202b0a1279487f5d04c9d9)
|
||||
- Replaced template renderer with regex-based renderer [`427c4c0`](https://github.com/RhetTbull/osxphotos/commit/427c4c0bc49f671477866d30eee74834c67d7bc5)
|
||||
|
||||
#### [v0.23.4](https://github.com/RhetTbull/osxphotos/compare/v0.23.3...v0.23.4)
|
||||
|
||||
> 22 March 2020
|
||||
|
||||
- Added export_by_album.py to examples [`908fead`](https://github.com/RhetTbull/osxphotos/commit/908fead8a2fbcef3b4a387f34d83d88c507c5939)
|
||||
- Updated 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)
|
||||
|
||||
> 22 March 2020
|
||||
|
||||
- Initial version of templating system for CLI [`2feb099`](https://github.com/RhetTbull/osxphotos/commit/2feb0999b3f9ffd9a24e37238f780239a027aa49)
|
||||
- Added __str__ to place [`ad58b03`](https://github.com/RhetTbull/osxphotos/commit/ad58b03f2d31daf33849b141570dd0fb5e0a262e)
|
||||
- Test library updates [`e90d9c6`](https://github.com/RhetTbull/osxphotos/commit/e90d9c6e11fce7a4e4aa348dcc5f57420c0b6c44)
|
||||
|
||||
#### [v0.23.1](https://github.com/RhetTbull/osxphotos/compare/v0.23.0...v0.23.1)
|
||||
|
||||
> 21 March 2020
|
||||
|
||||
- Fixed requirements.txt for bplist2 [`cda5f44`](https://github.com/RhetTbull/osxphotos/commit/cda5f446933ea2272409d1f153e2a7811626ada6)
|
||||
- Updated 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)
|
||||
- Update pythonpackage.yml [`92e5bdd`](https://github.com/RhetTbull/osxphotos/commit/92e5bdd2e986e5de2a710abf60ba0dc99c6a6730)
|
||||
|
||||
#### [v0.22.23](https://github.com/RhetTbull/osxphotos/compare/v0.22.21...v0.22.23)
|
||||
|
||||
> 15 March 2020
|
||||
|
||||
- Lots of work on export code [`0940f03`](https://github.com/RhetTbull/osxphotos/commit/0940f039d3e628dc4f25c69bf27ce413807d3f71)
|
||||
- test library update [`1e08a74`](https://github.com/RhetTbull/osxphotos/commit/1e08a7449e69965a37373dadabb37c993d93fc69)
|
||||
|
||||
#### [v0.22.21](https://github.com/RhetTbull/osxphotos/compare/v0.22.17...v0.22.21)
|
||||
|
||||
> 15 March 2020
|
||||
|
||||
- Working on export edited bug for issue #78 [`8542e1a`](https://github.com/RhetTbull/osxphotos/commit/8542e1a97f6b640f287b37af9e50fd05f964ec4d)
|
||||
- Fixed download-missing to only download when actually missing [`dd20b8d`](https://github.com/RhetTbull/osxphotos/commit/dd20b8d8ac3b16d3b72a26b97dcc620b11e3a7c0)
|
||||
- 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)
|
||||
|
||||
> 14 March 2020
|
||||
|
||||
- Added MANIFEST.in [`279ab36`](https://github.com/RhetTbull/osxphotos/commit/279ab369295cfe1c778b38e212248271e4fc659e)
|
||||
- version bump [`783e097`](https://github.com/RhetTbull/osxphotos/commit/783e097da35a210a2aa5c75865a8599541b9da0b)
|
||||
|
||||
#### [v0.22.16](https://github.com/RhetTbull/osxphotos/compare/v0.22.13...v0.22.16)
|
||||
|
||||
> 14 March 2020
|
||||
|
||||
- removed activate from --download-missing-photos Applescript, closes #69 [`#69`](https://github.com/RhetTbull/osxphotos/issues/69)
|
||||
- Added media type specials to json and string output, closes #68 [`#68`](https://github.com/RhetTbull/osxphotos/issues/68)
|
||||
- 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)
|
||||
|
||||
#### [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 README.md [`1f8fd6e`](https://github.com/RhetTbull/osxphotos/commit/1f8fd6e929cc0edd3dd2f222416454d26955bf2a)
|
||||
|
||||
#### [v0.22.12](https://github.com/RhetTbull/osxphotos/compare/0.22.10...v0.22.12)
|
||||
|
||||
> 7 March 2020
|
||||
|
||||
- Added exiftool [`8dea419`](https://github.com/RhetTbull/osxphotos/commit/8dea41961bad285be7058a68e5f7199e5cfb740e)
|
||||
- Added --exiftool to CLI export [`ef79961`](https://github.com/RhetTbull/osxphotos/commit/ef799610aea67b703a7d056b7eee227534ba78a5)
|
||||
- Updated test library [`9a0fc0d`](https://github.com/RhetTbull/osxphotos/commit/9a0fc0db3e79359610fd0f124a97b03fcf97d8a7)
|
||||
|
||||
#### [0.22.10](https://github.com/RhetTbull/osxphotos/compare/v0.22.9...0.22.10)
|
||||
|
||||
> 8 February 2020
|
||||
|
||||
- Fixed bug in --download-missing to fix issue #64 [`c654e3d`](https://github.com/RhetTbull/osxphotos/commit/c654e3dc61283382b37b6892dab1516ec517143a)
|
||||
- removed commented out code [`69addc3`](https://github.com/RhetTbull/osxphotos/commit/69addc34649f992c6a4a0e0e334754a72530f0ba)
|
||||
- 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)
|
||||
|
||||
> 1 February 2020
|
||||
@@ -14,7 +644,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)
|
||||
|
||||
@@ -28,7 +658,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)
|
||||
|
||||
@@ -40,7 +670,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)
|
||||
|
||||
@@ -48,7 +678,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)
|
||||
|
||||
@@ -63,8 +693,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)
|
||||
|
||||
@@ -78,9 +708,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)
|
||||
|
||||
|
||||
2
MANIFEST.in
Normal file
@@ -0,0 +1,2 @@
|
||||
include README.md
|
||||
include osxphotos/templates/*
|
||||
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()
|
||||
86
examples/export_by_album.py
Normal file
@@ -0,0 +1,86 @@
|
||||
""" Export all photos to specified directory using album names as folders
|
||||
If file has been edited, also export the edited version,
|
||||
otherwise, export the original version
|
||||
This will result in duplicate photos if photo is in more than album """
|
||||
|
||||
import os.path
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import click
|
||||
from pathvalidate import is_valid_filepath, sanitize_filepath
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("export_path", type=click.Path(exists=True))
|
||||
@click.option(
|
||||
"--default-album",
|
||||
help="Default folder for photos with no album. Defaults to 'unfiled'",
|
||||
default="unfiled",
|
||||
)
|
||||
@click.option(
|
||||
"--library-path",
|
||||
help="Path to Photos library, default to last used library",
|
||||
default=None,
|
||||
)
|
||||
@click.option(
|
||||
"--edited",
|
||||
help="Also export edited versions of photos (default is originals only)",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
)
|
||||
def export(export_path, default_album, library_path, edited):
|
||||
""" Export all photos, organized by album """
|
||||
export_path = os.path.expanduser(export_path)
|
||||
library_path = os.path.expanduser(library_path) if library_path else None
|
||||
|
||||
if library_path is not None:
|
||||
photosdb = osxphotos.PhotosDB(library_path)
|
||||
else:
|
||||
photosdb = osxphotos.PhotosDB()
|
||||
|
||||
photos = photosdb.photos()
|
||||
|
||||
for p in photos:
|
||||
if not p.ismissing:
|
||||
albums = p.albums
|
||||
if not albums:
|
||||
albums = [default_album]
|
||||
for album in albums:
|
||||
click.echo(f"exporting {p.original_filename} in album {album}")
|
||||
|
||||
# make sure no invalid characters in destination path (could be in album name)
|
||||
album_name = sanitize_filepath(album, platform="auto")
|
||||
|
||||
# create destination folder, if necessary, based on album name
|
||||
dest_dir = os.path.join(export_path, album_name)
|
||||
|
||||
# verify path is a valid path
|
||||
if not is_valid_filepath(dest_dir, platform="auto"):
|
||||
sys.exit(f"Invalid filepath {dest_dir}")
|
||||
|
||||
# create destination dir if needed
|
||||
if not os.path.isdir(dest_dir):
|
||||
os.makedirs(dest_dir)
|
||||
|
||||
filename = p.original_filename
|
||||
# export the photo but only if --edited, photo has adjustments, and
|
||||
# path_edited is not None (can be None if edited photo is missing)
|
||||
if edited and p.hasadjustments and p.path_edited:
|
||||
# export edited version
|
||||
# use original filename with _edited appended but make sure suffix is
|
||||
# same as edited file
|
||||
edited_filename = f"{pathlib.Path(filename).stem}_edited{pathlib.Path(p.path_edited).suffix}"
|
||||
exported = p.export(dest_dir, edited_filename, edited=True)
|
||||
click.echo(f"Exported {edited_filename} to {exported}")
|
||||
# export unedited version
|
||||
exported = p.export(dest_dir, filename)
|
||||
click.echo(f"Exported {filename} to {exported}")
|
||||
else:
|
||||
click.echo(f"Skipping missing photo: {p.original_filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
export() # pylint: disable=no-value-for-parameter
|
||||
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,18 +18,55 @@ 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"]
|
||||
|
||||
# versions later than this have a different database structure
|
||||
_PHOTOS_5_VERSION = "6000"
|
||||
# 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 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_"
|
||||
|
||||
# photos with no reverse geolocation info (place)
|
||||
_UNKNOWN_PLACE = "_UNKNOWN_"
|
||||
|
||||
_EXIF_TOOL_URL = "https://exiftool.org/"
|
||||
|
||||
# Where are shared iCloud photos located?
|
||||
@@ -34,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.22.10"
|
||||
__version__ = "0.34.5"
|
||||
|
||||
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))
|
||||
252
osxphotos/exiftool.py
Normal file
@@ -0,0 +1,252 @@
|
||||
""" Yet another simple exiftool wrapper
|
||||
I rolled my own for following reasons:
|
||||
1. I wanted something under MIT license (best alternative was licensed under GPL/BSD)
|
||||
2. I wanted singleton behavior so only a single exiftool process was ever running
|
||||
If these aren't important to you, I highly recommend you use Sven Marnach's excellent
|
||||
pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache # pylint: disable=syntax-error
|
||||
|
||||
from .utils import _debug
|
||||
|
||||
# exiftool -stay_open commands outputs this EOF marker after command is run
|
||||
EXIFTOOL_STAYOPEN_EOF = "{ready}"
|
||||
EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_exiftool_path():
|
||||
""" return path of exiftool, cache result """
|
||||
exiftool_path = shutil.which('exiftool')
|
||||
if _debug():
|
||||
logging.debug("exiftool path = %s" % (exiftool_path))
|
||||
if exiftool_path:
|
||||
return exiftool_path.rstrip()
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
"Could not find exiftool. Please download and install from "
|
||||
"https://exiftool.org/"
|
||||
)
|
||||
|
||||
|
||||
class _ExifToolProc:
|
||||
""" Runs exiftool in a subprocess via Popen
|
||||
Creates a singleton object """
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
""" create new object or return instance of already created singleton """
|
||||
if not hasattr(cls, "instance") or not cls.instance:
|
||||
cls.instance = super().__new__(cls)
|
||||
|
||||
return cls.instance
|
||||
|
||||
def __init__(self, exiftool=None):
|
||||
""" construct _ExifToolProc singleton object or return instance of already created object
|
||||
exiftool: optional path to exiftool binary (if not provided, will search path to find it) """
|
||||
|
||||
if hasattr(self, "_process_running") and self._process_running:
|
||||
# already running
|
||||
if exiftool is not None:
|
||||
logging.warning(
|
||||
f"exiftool subprocess already running, "
|
||||
f"ignoring exiftool={exiftool}"
|
||||
)
|
||||
return
|
||||
|
||||
self._exiftool = exiftool if exiftool else get_exiftool_path()
|
||||
self._process_running = False
|
||||
self._start_proc()
|
||||
|
||||
@property
|
||||
def process(self):
|
||||
""" return the exiftool subprocess """
|
||||
if self._process_running:
|
||||
return self._process
|
||||
else:
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
""" return process id (PID) of the exiftool process """
|
||||
return self._process.pid
|
||||
|
||||
@property
|
||||
def exiftool(self):
|
||||
""" return path to exiftool process """
|
||||
return self._exiftool
|
||||
|
||||
def _start_proc(self):
|
||||
""" start exiftool in batch mode """
|
||||
|
||||
if self._process_running:
|
||||
logging.warning("exiftool already running: {self._process}")
|
||||
return
|
||||
|
||||
# open exiftool process
|
||||
self._process = subprocess.Popen(
|
||||
[
|
||||
self._exiftool,
|
||||
"-stay_open", # keep process open in batch mode
|
||||
"True", # -stay_open=True, keep process open in batch mode
|
||||
"-@", # read command-line arguments from file
|
||||
"-", # read from stdin
|
||||
"-common_args", # specifies args common to all commands subsequently run
|
||||
"-n", # no print conversion (e.g. print tag values in machine readable format)
|
||||
"-P", # Preserve file modification date/time (possible interfere w/ --touch-file)
|
||||
"-G", # print group name for each tag
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self._process_running = True
|
||||
|
||||
def _stop_proc(self):
|
||||
""" stop the exiftool process if it's running, otherwise, do nothing """
|
||||
if not self._process_running:
|
||||
logging.warning("exiftool process is not running")
|
||||
return
|
||||
|
||||
self._process.stdin.write(b"-stay_open\n")
|
||||
self._process.stdin.write(b"False\n")
|
||||
self._process.stdin.flush()
|
||||
try:
|
||||
self._process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.warning(
|
||||
f"exiftool pid {self._process.pid} did not exit, killing it"
|
||||
)
|
||||
self._process.kill()
|
||||
self._process.communicate()
|
||||
|
||||
del self._process
|
||||
self._process_running = False
|
||||
|
||||
def __del__(self):
|
||||
self._stop_proc()
|
||||
|
||||
|
||||
class ExifTool:
|
||||
""" Basic exiftool interface for reading and writing EXIF tags """
|
||||
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True):
|
||||
""" Return ExifTool object
|
||||
file: path to image file
|
||||
exiftool: path to exiftool, if not specified will look in path
|
||||
overwrite: if True, will overwrite image file without creating backup, default=False """
|
||||
self.file = filepath
|
||||
self.overwrite = overwrite
|
||||
self.data = {}
|
||||
self._exiftoolproc = _ExifToolProc(exiftool=exiftool)
|
||||
self._process = self._exiftoolproc.process
|
||||
self._read_exif()
|
||||
|
||||
def setvalue(self, tag, value):
|
||||
""" Set tag to value(s)
|
||||
if value is None, will delete tag """
|
||||
|
||||
if value is None:
|
||||
value = ""
|
||||
command = [f"-{tag}={value}"]
|
||||
if self.overwrite:
|
||||
command.append("-overwrite_original")
|
||||
self.run_commands(*command)
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
""" Add one or more value(s) to tag
|
||||
If more than one value is passed, each value will be added to the tag
|
||||
Notes: exiftool may add duplicate values for some tags so the caller must ensure
|
||||
the values being added are not already in the EXIF data
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
|
||||
It's up to the caller to know what exiftool will do for each tag
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
but if addvalues called without first calling setvalue, exiftool will add duplicate values
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("Must pass at least one value")
|
||||
|
||||
command = []
|
||||
for value in values:
|
||||
if value is None:
|
||||
raise ValueError("Can't add None value to tag")
|
||||
command.append(f"-{tag}+={value}")
|
||||
|
||||
if self.overwrite:
|
||||
command.append("-overwrite_original")
|
||||
|
||||
if command:
|
||||
self.run_commands(*command)
|
||||
|
||||
def run_commands(self, *commands, no_file=False):
|
||||
""" run commands in the exiftool process and return result
|
||||
no_file: (bool) do not pass the filename to exiftool (default=False)
|
||||
by default, all commands will be run against self.file
|
||||
use no_file=True to run a command without passing the filename """
|
||||
if not (hasattr(self, "_process") and self._process):
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
if not commands:
|
||||
raise TypeError("must provide one or more command to run")
|
||||
|
||||
filename = os.fsencode(self.file) if not no_file else b""
|
||||
command_str = (
|
||||
b"\n".join([c.encode("utf-8") for c in commands])
|
||||
+ b"\n"
|
||||
+ filename
|
||||
+ b"\n"
|
||||
+ b"-execute\n"
|
||||
)
|
||||
|
||||
if _debug():
|
||||
logging.debug(command_str)
|
||||
|
||||
# send the command
|
||||
self._process.stdin.write(command_str)
|
||||
self._process.stdin.flush()
|
||||
|
||||
# read the output
|
||||
output = b""
|
||||
while EXIFTOOL_STAYOPEN_EOF not in str(output):
|
||||
output += self._process.stdout.readline().strip()
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN]
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
""" return process id (PID) of the exiftool process """
|
||||
return self._process.pid
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
""" returns exiftool version """
|
||||
ver = self.run_commands("-ver", no_file=True)
|
||||
return ver.decode("utf-8")
|
||||
|
||||
def 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:
|
||||
exifdict = json.loads(json_str)
|
||||
return exifdict[0]
|
||||
else:
|
||||
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 """
|
||||
data = self.as_dict()
|
||||
self.data = {k: v for k, v in data.items()}
|
||||
|
||||
def __str__(self):
|
||||
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
|
||||
1224
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,34 +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 .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:
|
||||
@@ -40,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
|
||||
@@ -48,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):
|
||||
@@ -59,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):
|
||||
@@ -75,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
|
||||
|
||||
@@ -93,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"])
|
||||
@@ -114,8 +133,6 @@ class PhotoInfo:
|
||||
)
|
||||
return photopath
|
||||
|
||||
# if self._info["masterFingerprint"]:
|
||||
# if masterFingerprint is not null, path appears to be valid
|
||||
if self._info["directory"].startswith("/"):
|
||||
photopath = os.path.join(self._info["directory"], self._info["filename"])
|
||||
else:
|
||||
@@ -124,13 +141,6 @@ class PhotoInfo:
|
||||
)
|
||||
return photopath
|
||||
|
||||
# if all else fails, photopath = None
|
||||
# photopath = None
|
||||
# logging.debug(
|
||||
# f"WARNING: photopath None, masterFingerprint null, not shared {pformat(self._info)}"
|
||||
# )
|
||||
# return photopath
|
||||
|
||||
@property
|
||||
def path_edited(self):
|
||||
""" absolute path on disk of the edited picture """
|
||||
@@ -141,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:
|
||||
@@ -241,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
|
||||
|
||||
@@ -253,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):
|
||||
@@ -315,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 """
|
||||
@@ -324,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
|
||||
@@ -336,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
|
||||
@@ -361,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
|
||||
@@ -383,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 []
|
||||
|
||||
@@ -404,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:
|
||||
@@ -453,315 +599,147 @@ class PhotoInfo:
|
||||
|
||||
return photopath
|
||||
|
||||
def export(
|
||||
@property
|
||||
def panorama(self):
|
||||
""" Returns True if photo is a panorama, otherwise False """
|
||||
return self._info["panorama"]
|
||||
|
||||
@property
|
||||
def slow_mo(self):
|
||||
""" Returns True if photo is a slow motion video, otherwise False """
|
||||
return self._info["slow_mo"]
|
||||
|
||||
@property
|
||||
def time_lapse(self):
|
||||
""" Returns True if photo is a time lapse video, otherwise False """
|
||||
return self._info["time_lapse"]
|
||||
|
||||
@property
|
||||
def hdr(self):
|
||||
""" Returns True if photo is an HDR photo, otherwise False """
|
||||
return self._info["hdr"]
|
||||
|
||||
@property
|
||||
def screenshot(self):
|
||||
""" Returns True if photo is an HDR photo, otherwise False """
|
||||
return self._info["screenshot"]
|
||||
|
||||
@property
|
||||
def portrait(self):
|
||||
""" Returns True if photo is a portrait, otherwise False """
|
||||
return self._info["portrait"]
|
||||
|
||||
@property
|
||||
def selfie(self):
|
||||
""" Returns True if photo is a selfie (front facing camera), otherwise False """
|
||||
return self._info["selfie"]
|
||||
|
||||
@property
|
||||
def place(self):
|
||||
""" Returns PlaceInfo object containing reverse geolocation info """
|
||||
|
||||
# implementation note: doesn't create the PlaceInfo object until requested
|
||||
# then memoizes the object in self._place to avoid recreating the object
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
try:
|
||||
return self._place # pylint: disable=access-member-before-definition
|
||||
except AttributeError:
|
||||
if self._info["placeNames"]:
|
||||
self._place = PlaceInfo4(
|
||||
self._info["placeNames"], self._info["countryCode"]
|
||||
)
|
||||
else:
|
||||
self._place = None
|
||||
return self._place
|
||||
else:
|
||||
try:
|
||||
return self._place # pylint: disable=access-member-before-definition
|
||||
except AttributeError:
|
||||
if self._info["reverse_geolocation"]:
|
||||
self._place = PlaceInfo5(self._info["reverse_geolocation"])
|
||||
else:
|
||||
self._place = None
|
||||
return self._place
|
||||
|
||||
@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,
|
||||
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 picture; if not provided, will use current filename
|
||||
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
|
||||
returns the full path to the exported file """
|
||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||
|
||||
# TODO: add this docs:
|
||||
# ( for jpeg in *.jpeg; do exiftool -v -json=$jpeg.json $jpeg; done )
|
||||
|
||||
# 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:
|
||||
# second arg is filename of picture
|
||||
filename = 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
|
||||
filename = (
|
||||
pathlib.Path(self.filename).stem + "_edited" + edited_suffix
|
||||
)
|
||||
else:
|
||||
filename = self.filename
|
||||
|
||||
# check destination path
|
||||
dest = pathlib.Path(dest)
|
||||
filename = pathlib.Path(filename)
|
||||
dest = dest / filename
|
||||
|
||||
# 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 not self.hasadjustments:
|
||||
logging.warning(
|
||||
"Attempting to export edited photo but hasadjustments=False"
|
||||
)
|
||||
|
||||
if self.path_edited is not None:
|
||||
src = self.path_edited
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"edited=True but path_edited is none; hasadjustments: {self.hasadjustments}"
|
||||
)
|
||||
else:
|
||||
if self.ismissing:
|
||||
logging.warning(
|
||||
f"Attempting to export photo with ismissing=True: path = {self.path}"
|
||||
)
|
||||
|
||||
if self.path is None:
|
||||
logging.warning(
|
||||
f"Attempting to export photo but path is None: ismissing = {self.ismissing}"
|
||||
)
|
||||
raise FileNotFoundError("Cannot export photo if path is None")
|
||||
else:
|
||||
src = self.path
|
||||
|
||||
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)
|
||||
|
||||
# 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))
|
||||
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"
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
original=False,
|
||||
edited=True,
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
if exported is None:
|
||||
logging.warning(f"Error exporting photo {self.uuid} to {dest}")
|
||||
|
||||
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
|
||||
|
||||
return str(dest)
|
||||
|
||||
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"
|
||||
exif["FileName"] = self.filename
|
||||
|
||||
if self.description:
|
||||
exif["ImageDescription"] = self.description
|
||||
exif["Description"] = self.description
|
||||
|
||||
if self.title:
|
||||
exif["Title"] = self.title
|
||||
|
||||
if self.keywords:
|
||||
exif["TagsList"] = exif["Keywords"] = list(self.keywords)
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
exif["Subject"] = list(self.keywords)
|
||||
|
||||
if self.persons:
|
||||
exif["PersonInImage"] = self.persons
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
if "Subject" in exif:
|
||||
exif["Subject"].extend(self.persons)
|
||||
else:
|
||||
exif["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["GPSLatitude"] = lat_str
|
||||
exif["GPSLongitude"] = lon_str
|
||||
exif["GPSPosition"] = f"{lat_str}, {lon_str}"
|
||||
lat_ref = "North" if lat >= 0 else "South"
|
||||
lon_ref = "East" if lon >= 0 else "West"
|
||||
exif["GPSLatitudeRef"] = lat_ref
|
||||
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["DateTimeOriginal"] = datetimeoriginal
|
||||
exif["OffsetTimeOriginal"] = offsettime
|
||||
|
||||
if self.date_modified is not None:
|
||||
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):
|
||||
@@ -773,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})"
|
||||
|
||||
@@ -783,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,
|
||||
@@ -813,6 +824,27 @@ class PhotoInfo:
|
||||
"iscloudasset": self.iscloudasset,
|
||||
"incloud": self.incloud,
|
||||
"date_modified": date_modified_iso,
|
||||
"portrait": self.portrait,
|
||||
"screenshot": self.screenshot,
|
||||
"slow_mo": self.slow_mo,
|
||||
"time_lapse": self.time_lapse,
|
||||
"hdr": self.hdr,
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
"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)
|
||||
|
||||
@@ -822,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,
|
||||
@@ -831,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,
|
||||
@@ -852,15 +891,42 @@ class PhotoInfo:
|
||||
"iscloudasset": self.iscloudasset,
|
||||
"incloud": self.incloud,
|
||||
"date_modified": date_modified_iso,
|
||||
"portrait": self.portrait,
|
||||
"screenshot": self.screenshot,
|
||||
"slow_mo": self.slow_mo,
|
||||
"time_lapse": self.time_lapse,
|
||||
"hdr": self.hdr,
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
"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()
|
||||
2706
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
|
||||
645
osxphotos/placeinfo.py
Normal file
@@ -0,0 +1,645 @@
|
||||
"""
|
||||
PlaceInfo class
|
||||
Provides reverse geolocation info for photos
|
||||
|
||||
See https://developer.apple.com/documentation/corelocation/clplacemark
|
||||
for additional documentation on reverse geolocation data
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import namedtuple # pylint: disable=syntax-error
|
||||
|
||||
import yaml
|
||||
from bpylist import archiver
|
||||
|
||||
from ._constants import UNICODE_FORMAT
|
||||
from .utils import normalize_unicode
|
||||
|
||||
# postal address information, returned by PlaceInfo.address
|
||||
PostalAddress = namedtuple(
|
||||
"PostalAddress",
|
||||
[
|
||||
"street",
|
||||
"sub_locality",
|
||||
"city",
|
||||
"sub_administrative_area",
|
||||
"state_province",
|
||||
"postal_code",
|
||||
"country",
|
||||
"iso_country_code",
|
||||
],
|
||||
)
|
||||
|
||||
# PlaceNames tuple returned by PlaceInfo.names
|
||||
# order of fields 0 - 17 is mapped to placeType value in
|
||||
# PLRevGeoLocationInfo.mapInfo.sortedPlaceInfos
|
||||
# field 18 is combined bodies of water (ocean + inland_water)
|
||||
# and maps to Photos <= 4, RKPlace.type == 44
|
||||
# (Photos <= 4 doesn't have ocean or inland_water types)
|
||||
# The fields named "field0", etc. appear to be unused
|
||||
PlaceNames = namedtuple(
|
||||
"PlaceNames",
|
||||
[
|
||||
"field0",
|
||||
"country", # The name of the country associated with the placemark.
|
||||
"state_province", # administrativeArea, The state or province associated with the placemark.
|
||||
"sub_administrative_area", # Additional administrative area information for the placemark.
|
||||
"city", # locality, The city associated with the placemark.
|
||||
"field5",
|
||||
"additional_city_info", # subLocality, Additional city-level information for the placemark.
|
||||
"ocean", # The name of the ocean associated with the placemark.
|
||||
"area_of_interest", # areasOfInterest, The relevant areas of interest associated with the placemark.
|
||||
"inland_water", # The name of the inland water body associated with the placemark.
|
||||
"field10",
|
||||
"region", # The geographic region associated with the placemark.
|
||||
"sub_throughfare", # Additional street-level information for the placemark.
|
||||
"field13",
|
||||
"postal_code", # The postal code associated with the placemark.
|
||||
"field15",
|
||||
"field16",
|
||||
"street_address", # throughfare, The street address associated with the placemark.
|
||||
"body_of_water", # RKPlace.type == 44, appears to be any body of water (ocean or inland)
|
||||
],
|
||||
)
|
||||
|
||||
# The following classes represent Photo Library Reverse Geolocation Info as stored
|
||||
# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
|
||||
# These classes are used by bpylist.archiver to unarchive the serialized objects
|
||||
class PLRevGeoLocationInfo:
|
||||
""" The top level reverse geolocation object """
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
addressString,
|
||||
countryCode,
|
||||
mapItem,
|
||||
isHome,
|
||||
compoundNames,
|
||||
compoundSecondaryNames,
|
||||
version,
|
||||
geoServiceProvider,
|
||||
postalAddress,
|
||||
):
|
||||
self.addressString = normalize_unicode(addressString)
|
||||
self.countryCode = countryCode
|
||||
self.mapItem = mapItem
|
||||
self.isHome = isHome
|
||||
self.compoundNames = normalize_unicode(compoundNames)
|
||||
self.compoundSecondaryNames = normalize_unicode(compoundSecondaryNames)
|
||||
self.version = version
|
||||
self.geoServiceProvider = geoServiceProvider
|
||||
self.postalAddress = postalAddress
|
||||
|
||||
def __eq__(self, other):
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
return f"addressString: {self.addressString}, countryCode: {self.countryCode}, isHome: {self.isHome}, mapItem: {self.mapItem}, postalAddress: {self.postalAddress}"
|
||||
|
||||
@staticmethod
|
||||
def encode_archive(obj, archive):
|
||||
archive.encode("addressString", obj.addressString)
|
||||
archive.encode("countryCode", obj.countryCode)
|
||||
archive.encode("mapItem", obj.mapItem)
|
||||
archive.encode("isHome", obj.isHome)
|
||||
archive.encode("compoundNames", obj.compoundNames)
|
||||
archive.encode("compoundSecondaryNames", obj.compoundSecondaryNames)
|
||||
archive.encode("version", obj.version)
|
||||
archive.encode("geoServiceProvider", obj.geoServiceProvider)
|
||||
archive.encode("postalAddress", obj.postalAddress)
|
||||
|
||||
@staticmethod
|
||||
def decode_archive(archive):
|
||||
addressString = archive.decode("addressString")
|
||||
countryCode = archive.decode("countryCode")
|
||||
mapItem = archive.decode("mapItem")
|
||||
isHome = archive.decode("isHome")
|
||||
compoundNames = archive.decode("compoundNames")
|
||||
compoundSecondaryNames = archive.decode("compoundSecondaryNames")
|
||||
version = archive.decode("version")
|
||||
geoServiceProvider = archive.decode("geoServiceProvider")
|
||||
postalAddress = archive.decode("postalAddress")
|
||||
return PLRevGeoLocationInfo(
|
||||
addressString,
|
||||
countryCode,
|
||||
mapItem,
|
||||
isHome,
|
||||
compoundNames,
|
||||
compoundSecondaryNames,
|
||||
version,
|
||||
geoServiceProvider,
|
||||
postalAddress,
|
||||
)
|
||||
|
||||
|
||||
class PLRevGeoMapItem:
|
||||
""" Stores the list of place names, organized by area """
|
||||
|
||||
def __init__(self, sortedPlaceInfos, finalPlaceInfos):
|
||||
self.sortedPlaceInfos = sortedPlaceInfos
|
||||
self.finalPlaceInfos = finalPlaceInfos
|
||||
|
||||
def __eq__(self, other):
|
||||
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 = [str(place) for place in self.sortedPlaceInfos]
|
||||
finalPlaceInfos = [str(place) for place in self.finalPlaceInfos]
|
||||
return (
|
||||
f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def encode_archive(obj, archive):
|
||||
archive.encode("sortedPlaceInfos", obj.sortedPlaceInfos)
|
||||
archive.encode("finalPlaceInfos", obj.finalPlaceInfos)
|
||||
|
||||
@staticmethod
|
||||
def decode_archive(archive):
|
||||
sortedPlaceInfos = archive.decode("sortedPlaceInfos")
|
||||
finalPlaceInfos = archive.decode("finalPlaceInfos")
|
||||
return PLRevGeoMapItem(sortedPlaceInfos, finalPlaceInfos)
|
||||
|
||||
|
||||
class PLRevGeoMapItemAdditionalPlaceInfo:
|
||||
""" Additional info about individual places """
|
||||
|
||||
def __init__(self, area, name, placeType, dominantOrderType):
|
||||
self.area = area
|
||||
self.name = normalize_unicode(name)
|
||||
self.placeType = placeType
|
||||
self.dominantOrderType = dominantOrderType
|
||||
|
||||
def __eq__(self, other):
|
||||
return all(
|
||||
getattr(self, field) == getattr(other, field)
|
||||
for field in ["area", "name", "placeType", "dominantOrderType"]
|
||||
)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
return f"area: {self.area}, name: {self.name}, placeType: {self.placeType}"
|
||||
|
||||
@staticmethod
|
||||
def encode_archive(obj, archive):
|
||||
archive.encode("area", obj.area)
|
||||
archive.encode("name", obj.name)
|
||||
archive.encode("placeType", obj.placeType)
|
||||
archive.encode("dominantOrderType", obj.dominantOrderType)
|
||||
|
||||
@staticmethod
|
||||
def decode_archive(archive):
|
||||
area = archive.decode("area")
|
||||
name = archive.decode("name")
|
||||
placeType = archive.decode("placeType")
|
||||
dominantOrderType = archive.decode("dominantOrderType")
|
||||
return PLRevGeoMapItemAdditionalPlaceInfo(
|
||||
area, name, placeType, dominantOrderType
|
||||
)
|
||||
|
||||
|
||||
class CNPostalAddress:
|
||||
""" postal address for the reverse geolocation info """
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
_ISOCountryCode,
|
||||
_city,
|
||||
_country,
|
||||
_postalCode,
|
||||
_state,
|
||||
_street,
|
||||
_subAdministrativeArea,
|
||||
_subLocality,
|
||||
):
|
||||
self._ISOCountryCode = _ISOCountryCode
|
||||
self._city = 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):
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
return ", ".join(
|
||||
map(
|
||||
str,
|
||||
[
|
||||
self._street,
|
||||
self._city,
|
||||
self._subLocality,
|
||||
self._subAdministrativeArea,
|
||||
self._state,
|
||||
self._postalCode,
|
||||
self._country,
|
||||
self._ISOCountryCode,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def encode_archive(obj, archive):
|
||||
archive.encode("_ISOCountryCode", obj._ISOCountryCode)
|
||||
archive.encode("_country", obj._country)
|
||||
archive.encode("_city", obj._city)
|
||||
archive.encode("_postalCode", obj._postalCode)
|
||||
archive.encode("_state", obj._state)
|
||||
archive.encode("_street", obj._street)
|
||||
archive.encode("_subAdministrativeArea", obj._subAdministrativeArea)
|
||||
archive.encode("_subLocality", obj._subLocality)
|
||||
|
||||
@staticmethod
|
||||
def decode_archive(archive):
|
||||
_ISOCountryCode = archive.decode("_ISOCountryCode")
|
||||
_country = archive.decode("_country")
|
||||
_city = archive.decode("_city")
|
||||
_postalCode = archive.decode("_postalCode")
|
||||
_state = archive.decode("_state")
|
||||
_street = archive.decode("_street")
|
||||
_subAdministrativeArea = archive.decode("_subAdministrativeArea")
|
||||
_subLocality = archive.decode("_subLocality")
|
||||
|
||||
return CNPostalAddress(
|
||||
_ISOCountryCode,
|
||||
_city,
|
||||
_country,
|
||||
_postalCode,
|
||||
_state,
|
||||
_street,
|
||||
_subAdministrativeArea,
|
||||
_subLocality,
|
||||
)
|
||||
|
||||
|
||||
# register the classes with bpylist.archiver
|
||||
archiver.update_class_map({"CNPostalAddress": CNPostalAddress})
|
||||
archiver.update_class_map(
|
||||
{"PLRevGeoMapItemAdditionalPlaceInfo": PLRevGeoMapItemAdditionalPlaceInfo}
|
||||
)
|
||||
archiver.update_class_map({"PLRevGeoMapItem": PLRevGeoMapItem})
|
||||
archiver.update_class_map({"PLRevGeoLocationInfo": PLRevGeoLocationInfo})
|
||||
|
||||
|
||||
class PlaceInfo(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def address_str(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def country_code(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def ishome(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def names(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def address(self):
|
||||
pass
|
||||
|
||||
|
||||
class PlaceInfo4(PlaceInfo):
|
||||
""" Reverse geolocation place info for a photo (Photos <= 4) """
|
||||
|
||||
def __init__(self, place_names, country_code):
|
||||
""" place_names: list of place name tuples in ascending order by area
|
||||
tuple fields are: modelID, place name, place type, area, e.g.
|
||||
[(5, "St James's Park", 45, 0),
|
||||
(4, 'Westminster', 16, 22097376),
|
||||
(3, 'London', 4, 1596146816),
|
||||
(2, 'England', 2, 180406091776),
|
||||
(1, 'United Kingdom', 1, 414681432064)]
|
||||
country_code: two letter country code for the country
|
||||
"""
|
||||
self._place_names = place_names
|
||||
self._country_code = country_code
|
||||
self._process_place_info()
|
||||
|
||||
@property
|
||||
def address_str(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def country_code(self):
|
||||
return self._country_code
|
||||
|
||||
@property
|
||||
def ishome(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
return self._names
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
return PostalAddress(None, None, None, None, None, None, None, None)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, type(self)):
|
||||
return False
|
||||
else:
|
||||
return (
|
||||
self._place_names == other._place_names
|
||||
and self._country_code == other._country_code
|
||||
)
|
||||
|
||||
def _process_place_info(self):
|
||||
""" Process place_names to set self._name and self._names """
|
||||
places = self._place_names
|
||||
|
||||
# build a dictionary where key is placetype
|
||||
places_dict = {}
|
||||
for p in places:
|
||||
# places in format:
|
||||
# [(5, "St James's Park", 45, 0), ]
|
||||
# 0: modelID
|
||||
# 1: name
|
||||
# 2: type
|
||||
# 3: area
|
||||
try:
|
||||
places_dict[p[2]].append((normalize_unicode(p[1]), p[3]))
|
||||
except KeyError:
|
||||
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
|
||||
place_info = [[]] * 19
|
||||
|
||||
# add the place names sorted by area (ascending)
|
||||
# in Photos <=4, possible place type values are:
|
||||
# 45: areasOfInterest (The relevant areas of interest associated with the placemark.)
|
||||
# 44: body of water (includes both inlandWater and ocean)
|
||||
# 43: subLocality (Additional city-level information for the placemark.
|
||||
# 16: locality (The city associated with the placemark.)
|
||||
# 4: subAdministrativeArea (Additional administrative area information for the placemark.)
|
||||
# 2: administrativeArea (The state or province associated with the placemark.)
|
||||
# 1: country
|
||||
# mapping = mapping from PlaceNames to field in places_dict
|
||||
# PlaceNames fields map to the placeType value in Photos5 (0..17)
|
||||
# but place type in Photos <=4 has different values
|
||||
# hence (3, 4) means PlaceNames[3] = places_dict[4] (sub_administrative_area)
|
||||
mapping = [(1, 1), (2, 2), (3, 4), (4, 16), (18, 44), (8, 45)]
|
||||
for field5, field4 in mapping:
|
||||
try:
|
||||
place_info[field5] = [
|
||||
p[0]
|
||||
for p in sorted(places_dict[field4], key=lambda place: place[1])
|
||||
]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
place_names = PlaceNames(*place_info)
|
||||
self._names = place_names
|
||||
|
||||
# build the name as it appears in Photos
|
||||
# the length of the name is at most 3 fields and appears to be based on available
|
||||
# reverse geolocation data in the following order (left to right, joined by ',')
|
||||
# always has country if available then either area of interest and city OR
|
||||
# city and state
|
||||
# e.g. 4, 2, 1 OR 8, 4, 1
|
||||
# 8 (45): area_of_interest
|
||||
# 4 (16): locality / city
|
||||
# 2 (2): administrative area (state/province)
|
||||
# 1 (1): country
|
||||
name_list = []
|
||||
if place_names[8]:
|
||||
name_list.append(place_names[8][0])
|
||||
if place_names[4]:
|
||||
name_list.append(place_names[4][0])
|
||||
elif place_names[4]:
|
||||
name_list.append(place_names[4][0])
|
||||
if place_names[2]:
|
||||
name_list.append(place_names[2][0])
|
||||
elif place_names[2]:
|
||||
name_list.append(place_names[2][0])
|
||||
|
||||
# add country
|
||||
if place_names[1]:
|
||||
name_list.append(place_names[1][0])
|
||||
|
||||
name = ", ".join(name_list)
|
||||
self._name = name if name != "" else None
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
info = {
|
||||
"name": self.name,
|
||||
"names": self.names,
|
||||
"country_code": self.country_code,
|
||||
}
|
||||
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):
|
||||
""" Reverse geolocation place info for a photo (Photos >= 5) """
|
||||
|
||||
def __init__(self, revgeoloc_bplist):
|
||||
""" revgeoloc_bplist: a binary plist blob containing
|
||||
a serialized PLRevGeoLocationInfo object """
|
||||
self._bplist = revgeoloc_bplist
|
||||
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
|
||||
self._process_place_info()
|
||||
|
||||
@property
|
||||
def address_str(self):
|
||||
""" returns the postal address as a string """
|
||||
return self._plrevgeoloc.addressString
|
||||
|
||||
@property
|
||||
def country_code(self):
|
||||
""" returns the country code """
|
||||
return self._plrevgeoloc.countryCode
|
||||
|
||||
@property
|
||||
def ishome(self):
|
||||
""" returns True if place is user's home address """
|
||||
return self._plrevgeoloc.isHome
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" returns local place name """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
""" returns PlaceNames tuple with detailed reverse geolocation place names """
|
||||
return self._names
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
addr = self._plrevgeoloc.postalAddress
|
||||
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 """
|
||||
places = self._plrevgeoloc.mapItem.sortedPlaceInfos
|
||||
|
||||
# build a dictionary where key is placetype
|
||||
places_dict = {}
|
||||
for p in places:
|
||||
try:
|
||||
places_dict[p.placeType].append((p.name, p.area))
|
||||
except KeyError:
|
||||
places_dict[p.placeType] = [(p.name, p.area)]
|
||||
|
||||
# build list to populate PlaceNames tuple
|
||||
place_info = []
|
||||
for field in range(18):
|
||||
try:
|
||||
# add the place names sorted by area (ascending)
|
||||
place_info.append(
|
||||
[
|
||||
p[0]
|
||||
for p in sorted(places_dict[field], key=lambda place: place[1])
|
||||
]
|
||||
)
|
||||
except:
|
||||
place_info.append([])
|
||||
|
||||
# fill in body_of_water for compatibility with Photos <= 4
|
||||
place_info.append(place_info[7] + place_info[9])
|
||||
|
||||
place_names = PlaceNames(*place_info)
|
||||
self._names = place_names
|
||||
|
||||
# build the name as it appears in Photos
|
||||
# the length of the name is variable and appears to be based on available
|
||||
# reverse geolocation data in the following order (left to right, joined by ',')
|
||||
# 8: area_of_interest
|
||||
# 11: region (I've only seen this applied to islands)
|
||||
# 4: locality / city
|
||||
# 2: administrative area (state/province)
|
||||
# 1: country
|
||||
# 9: inland_water
|
||||
# 7: ocean
|
||||
name = ", ".join(
|
||||
[
|
||||
p[0]
|
||||
for p in [
|
||||
place_names[8], # area of interest
|
||||
place_names[11], # region (I've only seen this applied to islands)
|
||||
place_names[4], # locality / city
|
||||
place_names[2], # administrative area (state/province)
|
||||
place_names[1], # country
|
||||
place_names[9], # inland_water
|
||||
place_names[7], # ocean
|
||||
]
|
||||
if p and p[0]
|
||||
]
|
||||
)
|
||||
self._name = name if name != "" else None
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, type(self)):
|
||||
return False
|
||||
else:
|
||||
return self._plrevgeoloc == other._plrevgeoloc
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
info = {
|
||||
"name": self.name,
|
||||
"names": self.names,
|
||||
"country_code": self.country_code,
|
||||
"ishome": self.ishome,
|
||||
"address_str": self.address_str,
|
||||
"address": str(self.address),
|
||||
}
|
||||
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,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,19 +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
|
||||
import pathlib
|
||||
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
|
||||
|
||||
@@ -113,31 +120,6 @@ def _dd_to_dms(dd):
|
||||
return int(deg_), int(min_), sec_
|
||||
|
||||
|
||||
def _copy_file(src, dest):
|
||||
""" Copies a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
Uses ditto to perform copy; will silently overwrite dest if it exists
|
||||
Raises exception if copy fails or either path is None """
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise ValueError("src file does not appear to exist", src)
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
subprocess.run(
|
||||
["/usr/bin/ditto", src, dest], check=True, stderr=subprocess.PIPE
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.critical(
|
||||
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
def dd_to_dms_str(lat, lon):
|
||||
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
|
||||
""" lat: latitude in degrees """
|
||||
@@ -169,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:
|
||||
@@ -186,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():
|
||||
@@ -214,10 +190,12 @@ 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
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=undefined-variable
|
||||
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
|
||||
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
|
||||
)
|
||||
@@ -255,7 +233,7 @@ def list_photo_libraries():
|
||||
# On older MacOS versions, mdfind appears to ignore some libraries
|
||||
# glob to find libraries in ~/Pictures then mdfind to find all the others
|
||||
# TODO: make this more robust
|
||||
lib_list = glob.glob(f"{str(Path.home())}/Pictures/*.photoslibrary")
|
||||
lib_list = glob.glob(f"{str(pathlib.Path.home())}/Pictures/*.photoslibrary")
|
||||
|
||||
# On older OS, may not get all libraries so make sure we get the last one
|
||||
last_lib = get_last_library_path()
|
||||
@@ -272,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
|
||||
@@ -299,8 +283,7 @@ def create_path_by_date(dest, dt):
|
||||
# f"""
|
||||
# on openLibrary
|
||||
# tell application "Photos"
|
||||
# activate
|
||||
# open POSIX file "{library_path}"
|
||||
# open POSIX file "{library_path}"
|
||||
# end tell
|
||||
# end openLibrary
|
||||
# """
|
||||
@@ -308,110 +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,
|
||||
):
|
||||
""" 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
|
||||
will produce an error if image does not have edits/adjustments
|
||||
*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
|
||||
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"
|
||||
activate
|
||||
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
|
||||
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 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) """
|
||||
@@ -443,8 +322,45 @@ def _db_is_locked(dbname):
|
||||
conn.close()
|
||||
logging.debug(f"{dbname} is not locked")
|
||||
locked = False
|
||||
except Exception as e:
|
||||
except:
|
||||
logging.debug(f"{dbname} is locked")
|
||||
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
|
||||
|
||||
306
requirements.txt
@@ -1,146 +1,206 @@
|
||||
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
|
||||
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
|
||||
Mako==1.1.1
|
||||
MarkupSafe==1.1.1
|
||||
mccabe==0.6.1
|
||||
modulegraph==0.18
|
||||
more-itertools==7.2.0
|
||||
-e git+https://github.com/RhetTbull/osxphotos.git@0271b8ad9daf8b2fb80ce81e894478370e421379#egg=osxphotos
|
||||
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
|
||||
Pygments==2.4.2
|
||||
py2app==0.21
|
||||
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
|
||||
pytest==5.3.1
|
||||
pytest-cov==2.8.1
|
||||
pytest-sugar==0.9.2
|
||||
python-dateutil==2.8.1
|
||||
PyYAML==5.1.2
|
||||
six==1.12.0
|
||||
pyzmq==18.1.1
|
||||
readme-renderer==25.0
|
||||
regex==2020.2.20
|
||||
requests==2.23.0
|
||||
requests-toolbelt==0.9.1
|
||||
six==1.14.0
|
||||
termcolor==1.1.0
|
||||
wcwidth==0.1.7
|
||||
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
|
||||
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
|
||||
Mako==1.1.1
|
||||
34
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,30 +27,39 @@
|
||||
# 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",
|
||||
url="https://github.com/RhetTbull/",
|
||||
project_urls={"GitHub": "https://github.com/RhetTbull/osxphotos"},
|
||||
download_url="https://github.com/RhetTbull/osxphotos",
|
||||
packages=find_packages(exclude=["tests", "examples"]),
|
||||
packages=find_packages(exclude=["tests", "examples", "utils"]),
|
||||
license="License :: OSI Approved :: MIT License",
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -58,9 +67,18 @@ 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", "Click>=7", "PyYAML>=5.1.2", "Mako>=1.1.1"],
|
||||
install_requires=[
|
||||
"pyobjc>=6.2.2",
|
||||
"Click>=7",
|
||||
"PyYAML>=5.1.2",
|
||||
"Mako>=1.1.1",
|
||||
"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 |