Compare commits
357 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e3c40bcbaa | ||
|
|
69addc3464 | ||
|
|
c654e3dc61 | ||
|
|
1e013b6802 | ||
|
|
640471eba9 | ||
|
|
c346003059 | ||
|
|
46d3c7dbda | ||
|
|
476e094365 | ||
|
|
0bb579ee87 | ||
|
|
91d5729bea | ||
|
|
fdf636ac88 | ||
|
|
b6fe2b55e0 | ||
|
|
6e563e214c | ||
|
|
ac8be51156 | ||
|
|
27994c9fd3 | ||
|
|
b9c360cd20 | ||
|
|
f50cdd5403 | ||
|
|
1c792a371f | ||
|
|
8e11e237ef | ||
|
|
675867a3d3 | ||
|
|
f910124fe1 | ||
|
|
e79cb92693 | ||
|
|
f7c8b457a5 | ||
|
|
4dfb131a21 | ||
|
|
ba224af3fb | ||
|
|
4a1adaa156 | ||
|
|
9ee96c55e0 | ||
|
|
1261425d00 | ||
|
|
06cefcc7d2 | ||
|
|
67b0ae0bf6 | ||
|
|
4d36b3b31f | ||
|
|
fd8d466e05 | ||
|
|
898d3afc08 | ||
|
|
a0fd52deea | ||
|
|
9ce4566d0f | ||
|
|
46c6b6d130 | ||
|
|
ab1d95e458 | ||
|
|
db5effde52 | ||
|
|
50b7e6920a | ||
|
|
d37e6d9725 | ||
|
|
0aff83ff21 | ||
|
|
d1afd55a7c | ||
|
|
2908a6c3a7 | ||
|
|
c7d11d410f | ||
|
|
cfa2b4a828 | ||
|
|
f1e872401c | ||
|
|
bed7378039 | ||
|
|
f0b18c3d29 | ||
|
|
b9dee4995c | ||
|
|
7150956a48 | ||
|
|
b544e2f171 | ||
|
|
216d0ca0f7 | ||
|
|
a2067709cc | ||
|
|
6e364fc9d9 | ||
|
|
e214746063 | ||
|
|
694606e1a7 | ||
|
|
57aaa4eeb7 | ||
|
|
a8934d24be | ||
|
|
44ac9d3c0c | ||
|
|
ede56ffc31 | ||
|
|
ba1a2b32ad | ||
|
|
646ea4f24c | ||
|
|
99d3069530 | ||
|
|
de05323a15 | ||
|
|
bd20388778 | ||
|
|
146d54197f | ||
|
|
f3674ef58b | ||
|
|
a2dd648c89 | ||
|
|
66cabf1af2 | ||
|
|
11bc008a91 | ||
|
|
1015ca34b6 | ||
|
|
af52d8710c | ||
|
|
58b76493ed | ||
|
|
edb31f796a | ||
|
|
e5747094e5 | ||
|
|
e089d135d3 | ||
|
|
ff96448dee | ||
|
|
78b5f1a19d | ||
|
|
f0712a7c06 | ||
|
|
53ac45af50 | ||
|
|
9fd2e6d6d5 | ||
|
|
51a3adf169 | ||
|
|
c5e1208c1c | ||
|
|
24b43b5e4d | ||
|
|
5473f3b3fd | ||
|
|
9b5a1a64b0 | ||
|
|
e39936c2dc | ||
|
|
2860ccf7d5 | ||
|
|
fcc0e1d083 | ||
|
|
0dac64409b | ||
|
|
f484737940 | ||
|
|
eacd2ab12c | ||
|
|
1b7823e826 | ||
|
|
6f6d37ceac | ||
|
|
5099fd7715 | ||
|
|
d5eaff02f2 | ||
|
|
9fb05e4dd1 | ||
|
|
1a89a18a01 | ||
|
|
996b8285cf | ||
|
|
00ecb7fea8 | ||
|
|
962052bc33 | ||
|
|
2772bbff74 | ||
|
|
593983a099 | ||
|
|
1136f84d9b | ||
|
|
2e1a8d2500 | ||
|
|
05dea3afae | ||
|
|
a550ba00d6 | ||
|
|
2ec29f26e7 | ||
|
|
f493ca4b7f | ||
|
|
d589fcb9b1 | ||
|
|
980f62bdd5 | ||
|
|
10b59706be | ||
|
|
ac6bf019b2 | ||
|
|
f2d3741496 | ||
|
|
4a14287171 | ||
|
|
d3f1bf7b1c | ||
|
|
9cd5363a80 | ||
|
|
bfcab0c4fe | ||
|
|
51843fb46d | ||
|
|
6f4d129f07 | ||
|
|
b030966051 | ||
|
|
131dff4ea5 | ||
|
|
dbe363e4d7 | ||
|
|
b4351d4d2f | ||
|
|
8f67b45070 | ||
|
|
815d7fda75 | ||
|
|
7905a6c1fe | ||
|
|
e4d700fcff | ||
|
|
588ba4b9cb | ||
|
|
db3416903e | ||
|
|
5bec99eac9 | ||
|
|
0429c8f888 | ||
|
|
55974f20be | ||
|
|
a207f3558c | ||
|
|
0271b8ad9d | ||
|
|
6d20e9e361 | ||
|
|
10284d6589 | ||
|
|
abc628bf8a | ||
|
|
d7f50be622 | ||
|
|
e662e8aa02 | ||
|
|
5fd02a5f74 | ||
|
|
2917bb78ff | ||
|
|
c199e45245 | ||
|
|
4ce09332c0 | ||
|
|
aee52e4cb9 | ||
|
|
098159466e | ||
|
|
8682d56050 | ||
|
|
ce50b4f893 | ||
|
|
ff260dc072 | ||
|
|
a81c19a01a | ||
|
|
b2a1c53d04 | ||
|
|
40167d7a67 | ||
|
|
57485247fc | ||
|
|
57f6a282d6 | ||
|
|
048e80599e | ||
|
|
d73f6651f9 | ||
|
|
2519104928 | ||
|
|
8a00318399 | ||
|
|
9cdeeb389c | ||
|
|
2a5f0a2299 | ||
|
|
8ee8a38f0f | ||
|
|
2906773ba1 | ||
|
|
f643e79afd | ||
|
|
e5c50fa944 | ||
|
|
9fcc7379e6 | ||
|
|
d95acdf9f8 | ||
|
|
1ddd90cbdc | ||
|
|
4f449087a6 | ||
|
|
2dc7bccfb7 | ||
|
|
190adea3fc | ||
|
|
4ac9c1a7a8 | ||
|
|
b794e226e3 | ||
|
|
cd51782ef2 | ||
|
|
18395933a5 | ||
|
|
591db8b5a6 | ||
|
|
eb7ec9b5c6 | ||
|
|
1fe885962e | ||
|
|
b35e9d73ab | ||
|
|
c7b2b233e9 | ||
|
|
bea1683b94 | ||
|
|
bf8aed69cf | ||
|
|
800daf3658 | ||
|
|
d5a5bd41b3 | ||
|
|
911804317b | ||
|
|
aaba5cabf3 | ||
|
|
7fef67f852 | ||
|
|
62fedc7fbf | ||
|
|
1d006a4b50 | ||
|
|
2cedaedebb | ||
|
|
22d747ebab | ||
|
|
c1c7b0092d | ||
|
|
0220b0eaff | ||
|
|
d22affebd7 | ||
|
|
da320e4f56 | ||
|
|
591336673a | ||
|
|
906b6c0911 | ||
|
|
f21c7c8b08 | ||
|
|
1ea83e6c8e | ||
|
|
45da323840 | ||
|
|
def6f8cdfe | ||
|
|
6e45cf9591 | ||
|
|
7fb0bad6be | ||
|
|
811946018d | ||
|
|
2a0f27ca57 | ||
|
|
ff4066c49c | ||
|
|
1cf3e4b954 | ||
|
|
0219a9b4da | ||
|
|
b3c798033c | ||
|
|
9777e27e3a | ||
|
|
3a1ca343a6 | ||
|
|
42baa29c18 | ||
|
|
6a2be3e7d9 | ||
|
|
9c32d77b2c | ||
|
|
eb563ad297 | ||
|
|
d056b6f8c0 | ||
|
|
5a1176ed86 | ||
|
|
e77b8e8a0a | ||
|
|
55a6b5bf1c | ||
|
|
37dfc1e151 | ||
|
|
01b2f4420f | ||
|
|
68eef42599 | ||
|
|
3dc0943453 | ||
|
|
7818fe0fcf | ||
|
|
792fff13b8 | ||
|
|
b2242da9b7 | ||
|
|
1e5633efc8 | ||
|
|
27b3469513 | ||
|
|
aa25c9eab7 | ||
|
|
44321da243 | ||
|
|
67127ef370 | ||
|
|
51e720dce9 | ||
|
|
45b8150d5f | ||
|
|
6ebacb7f38 | ||
|
|
1b17608a02 | ||
|
|
d4353d48bd | ||
|
|
5af2b3e039 | ||
|
|
b36b7e7eb2 | ||
|
|
99be93d88f | ||
|
|
d840c11ddb | ||
|
|
b1fd019120 | ||
|
|
8b4a386c13 | ||
|
|
6abd10eddf | ||
|
|
aa73c2f055 | ||
|
|
966954340b | ||
|
|
7732708cb2 | ||
|
|
a7d9f72372 | ||
|
|
eabda3ea93 | ||
|
|
a995a8d610 | ||
|
|
ecd75c775d | ||
|
|
ae7fc69b33 | ||
|
|
83186a655d | ||
|
|
243492df88 | ||
|
|
986a092130 | ||
|
|
1c323338c6 | ||
|
|
b005f70133 | ||
|
|
9023a69073 | ||
|
|
e7958c94e8 | ||
|
|
17bc04a24a | ||
|
|
e0b1113870 | ||
|
|
10f0cf1092 | ||
|
|
a4b5f2a501 | ||
|
|
02fcfbca2c | ||
|
|
7eff015439 | ||
|
|
1c7e81b578 | ||
|
|
8649cda11f | ||
|
|
0c152c8c91 | ||
|
|
b0a9e87d00 | ||
|
|
fc6d7b1cf5 | ||
|
|
5234567974 | ||
|
|
f62a9d3d4e | ||
|
|
d311431c91 | ||
|
|
572e32b71b | ||
|
|
3744a596b5 | ||
|
|
221df0029e | ||
|
|
a1038314e2 |
34
.github/workflows/pythonpackage.yml
vendored
Normal file
34
.github/workflows/pythonpackage.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Python package
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: macOS-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.6, 3.7, 3.8]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# pip install flake8
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pip install pytest
|
||||
python -m pytest tests/
|
||||
260
CHANGELOG.md
Normal file
260
CHANGELOG.md
Normal file
@@ -0,0 +1,260 @@
|
||||
### Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v0.23.3](https://github.com/RhetTbull/osxphotos/compare/v0.23.1...v0.23.3)
|
||||
|
||||
> 22 March 2020
|
||||
|
||||
- Initial version of templating system for CLI [`2feb099`](https://github.com/RhetTbull/osxphotos/commit/2feb0999b3f9ffd9a24e37238f780239a027aa49)
|
||||
- Added __str__ to place [`ad58b03`](https://github.com/RhetTbull/osxphotos/commit/ad58b03f2d31daf33849b141570dd0fb5e0a262e)
|
||||
- Test library updates [`e90d9c6`](https://github.com/RhetTbull/osxphotos/commit/e90d9c6e11fce7a4e4aa348dcc5f57420c0b6c44)
|
||||
|
||||
#### [v0.23.1](https://github.com/RhetTbull/osxphotos/compare/v0.23.0...v0.23.1)
|
||||
|
||||
> 21 March 2020
|
||||
|
||||
- Fixed requirements.txt for bplist2 [`cda5f44`](https://github.com/RhetTbull/osxphotos/commit/cda5f446933ea2272409d1f153e2a7811626ada6)
|
||||
- Updated CHANGELOG.md [`b8da976`](https://github.com/RhetTbull/osxphotos/commit/b8da9765b8949eb90852d249c2877eeb1806d987)
|
||||
- Updated requirements.txt [`9da7ad6`](https://github.com/RhetTbull/osxphotos/commit/9da7ad6dcc021fdafe358d74e1c52f69dc49ade8)
|
||||
|
||||
#### [v0.23.0](https://github.com/RhetTbull/osxphotos/compare/v0.22.23...v0.23.0)
|
||||
|
||||
> 21 March 2020
|
||||
|
||||
- Added PhotoInfo.place for reverse geolocation data [`b338b34`](https://github.com/RhetTbull/osxphotos/commit/b338b34d5055a7621e4ebe4fbbae12227d77af6d)
|
||||
- Updated CHANGELOG.md [`816b98e`](https://github.com/RhetTbull/osxphotos/commit/816b98e617c30d0bdb51bc2413f9915742c8592e)
|
||||
- Update pythonpackage.yml [`92e5bdd`](https://github.com/RhetTbull/osxphotos/commit/92e5bdd2e986e5de2a710abf60ba0dc99c6a6730)
|
||||
|
||||
#### [v0.22.23](https://github.com/RhetTbull/osxphotos/compare/v0.22.21...v0.22.23)
|
||||
|
||||
> 15 March 2020
|
||||
|
||||
- Lots of work on export code [`0940f03`](https://github.com/RhetTbull/osxphotos/commit/0940f039d3e628dc4f25c69bf27ce413807d3f71)
|
||||
- test library update [`1e08a74`](https://github.com/RhetTbull/osxphotos/commit/1e08a7449e69965a37373dadabb37c993d93fc69)
|
||||
|
||||
#### [v0.22.21](https://github.com/RhetTbull/osxphotos/compare/v0.22.17...v0.22.21)
|
||||
|
||||
> 15 March 2020
|
||||
|
||||
- Working on export edited bug for issue #78 [`8542e1a`](https://github.com/RhetTbull/osxphotos/commit/8542e1a97f6b640f287b37af9e50fd05f964ec4d)
|
||||
- Fixed download-missing to only download when actually missing [`dd20b8d`](https://github.com/RhetTbull/osxphotos/commit/dd20b8d8ac3b16d3b72a26b97dcc620b11e3a7c0)
|
||||
- Updated CHANGELOG.md [`cc9220e`](https://github.com/RhetTbull/osxphotos/commit/cc9220e0763816d784f2fd8377dfe14a99981622)
|
||||
|
||||
#### [v0.22.17](https://github.com/RhetTbull/osxphotos/compare/v0.22.16...v0.22.17)
|
||||
|
||||
> 14 March 2020
|
||||
|
||||
- Added MANIFEST.in [`279ab36`](https://github.com/RhetTbull/osxphotos/commit/279ab369295cfe1c778b38e212248271e4fc659e)
|
||||
- version bump [`783e097`](https://github.com/RhetTbull/osxphotos/commit/783e097da35a210a2aa5c75865a8599541b9da0b)
|
||||
|
||||
#### [v0.22.16](https://github.com/RhetTbull/osxphotos/compare/v0.22.13...v0.22.16)
|
||||
|
||||
> 14 March 2020
|
||||
|
||||
- removed activate from --download-missing-photos Applescript, closes #69 [`#69`](https://github.com/RhetTbull/osxphotos/issues/69)
|
||||
- Added media type specials to json and string output, closes #68 [`#68`](https://github.com/RhetTbull/osxphotos/issues/68)
|
||||
- Added query/export options for special media types [`2b7d84a`](https://github.com/RhetTbull/osxphotos/commit/2b7d84a4d103982ad874d875bafbc34d654d539a)
|
||||
- README.md update [`a27ce33`](https://github.com/RhetTbull/osxphotos/commit/a27ce33473df3260dfb7ed26e28295cbf87d1e78)
|
||||
- Test library updates [`2d7d0b8`](https://github.com/RhetTbull/osxphotos/commit/2d7d0b86e0008cae043e314937504f36ad882990)
|
||||
- Fixed bug in --download-missing related to burst images [`1f13ba8`](https://github.com/RhetTbull/osxphotos/commit/1f13ba837fe36ff4eeb48cca02f5312a88a0a765)
|
||||
- test library update [`acb6b9e`](https://github.com/RhetTbull/osxphotos/commit/acb6b9e72f7f6b8f4f1d64b46f270a4d3e984fef)
|
||||
|
||||
#### [v0.22.13](https://github.com/RhetTbull/osxphotos/compare/v0.22.12...v0.22.13)
|
||||
|
||||
> 8 March 2020
|
||||
|
||||
- Added media type specials, closes #60 [`#60`](https://github.com/RhetTbull/osxphotos/issues/60)
|
||||
- Updated CHANGELOG.md [`08a9793`](https://github.com/RhetTbull/osxphotos/commit/08a9793651481e1984a4482794ffedd48e4367a2)
|
||||
- Updated README.md [`1f8fd6e`](https://github.com/RhetTbull/osxphotos/commit/1f8fd6e929cc0edd3dd2f222416454d26955bf2a)
|
||||
|
||||
#### [v0.22.12](https://github.com/RhetTbull/osxphotos/compare/0.22.10...v0.22.12)
|
||||
|
||||
> 7 March 2020
|
||||
|
||||
- Added exiftool [`8dea419`](https://github.com/RhetTbull/osxphotos/commit/8dea41961bad285be7058a68e5f7199e5cfb740e)
|
||||
- Added --exiftool to CLI export [`ef79961`](https://github.com/RhetTbull/osxphotos/commit/ef799610aea67b703a7d056b7eee227534ba78a5)
|
||||
- Updated test library [`9a0fc0d`](https://github.com/RhetTbull/osxphotos/commit/9a0fc0db3e79359610fd0f124a97b03fcf97d8a7)
|
||||
|
||||
#### [0.22.10](https://github.com/RhetTbull/osxphotos/compare/v0.22.9...0.22.10)
|
||||
|
||||
> 8 February 2020
|
||||
|
||||
- Fixed bug in --download-missing to fix issue #64 [`c654e3d`](https://github.com/RhetTbull/osxphotos/commit/c654e3dc61283382b37b6892dab1516ec517143a)
|
||||
- removed commented out code [`69addc3`](https://github.com/RhetTbull/osxphotos/commit/69addc34649f992c6a4a0e0e334754a72530f0ba)
|
||||
- Updated CHANGELOG.md [`1e013b6`](https://github.com/RhetTbull/osxphotos/commit/1e013b6802e49e26ec5a94eb702e841b2eb68395)
|
||||
|
||||
#### [v0.22.9](https://github.com/RhetTbull/osxphotos/compare/v0.22.7...v0.22.9)
|
||||
|
||||
> 1 February 2020
|
||||
|
||||
- Updated PhotosDB to only copy database if locked, speed improvement for cases where DB not locked; closes #34 [`#34`](https://github.com/RhetTbull/osxphotos/issues/34)
|
||||
- Changed temp file handling to use tempfile.TemporaryDirectory, closes #59 [`#59`](https://github.com/RhetTbull/osxphotos/issues/59)
|
||||
- Slight refactor to PhotosDB.photos() [`91d5729`](https://github.com/RhetTbull/osxphotos/commit/91d5729beaa0f0c2583e6320b18d958429e66075)
|
||||
- Test library updates [`6e563e2`](https://github.com/RhetTbull/osxphotos/commit/6e563e214c569ba7838f7464de9258c3bba5db23)
|
||||
- Removed _tmp_file code that's no longer needed [`27994c9`](https://github.com/RhetTbull/osxphotos/commit/27994c9fd372303833a5794f1de9815f425c762e)
|
||||
- Updated photos_repl.py [`fdf636a`](https://github.com/RhetTbull/osxphotos/commit/fdf636ac8864ebb2cc324b1f9d3c6c82ee3910f9)
|
||||
- Updated CHANGELOG.md [`f910124`](https://github.com/RhetTbull/osxphotos/commit/f910124fe1fbf75d44c09c79607374bf000733a1)
|
||||
|
||||
#### [v0.22.7](https://github.com/RhetTbull/osxphotos/compare/v0.22.4...v0.22.7)
|
||||
|
||||
> 27 January 2020
|
||||
|
||||
- Corrected Panorama Flag [`#58`](https://github.com/RhetTbull/osxphotos/pull/58)
|
||||
- Jan 20 Updates [`#1`](https://github.com/RhetTbull/osxphotos/pull/1)
|
||||
- Added XMP sidecar option to export, closes #51 [`#51`](https://github.com/RhetTbull/osxphotos/issues/51)
|
||||
- Test library updates, closes #52 [`#52`](https://github.com/RhetTbull/osxphotos/issues/52)
|
||||
- Added XMP sidecar to export [`4dfb131`](https://github.com/RhetTbull/osxphotos/commit/4dfb131a21b1b1efefe3b918ecb06fc6fcb03f2c)
|
||||
- Added date_modified to PhotoInfo [`67b0ae0`](https://github.com/RhetTbull/osxphotos/commit/67b0ae0bf679815372d415c3064e21d46a5b8718)
|
||||
- Added date_modified to PhotoInfo [`4d36b3b`](https://github.com/RhetTbull/osxphotos/commit/4d36b3b31f3e0e74d9d111b6b691771e19f94086)
|
||||
- Updated CLI options with more descriptive metavar names [`e79cb92`](https://github.com/RhetTbull/osxphotos/commit/e79cb92693758c984dc789d5fa5d2e87e381e921)
|
||||
- CLI now looks for photos library to use if non specified by user [`50b7e69`](https://github.com/RhetTbull/osxphotos/commit/50b7e6920a694aa45f478d1131868525c9147919)
|
||||
|
||||
#### [v0.22.4](https://github.com/RhetTbull/osxphotos/compare/v0.22.0...v0.22.4)
|
||||
|
||||
> 20 January 2020
|
||||
|
||||
- Add --from-date and --to-date to query and export command [`#57`](https://github.com/RhetTbull/osxphotos/pull/57)
|
||||
- Refactor CLI [`#55`](https://github.com/RhetTbull/osxphotos/pull/55)
|
||||
- Refactor cli: singular --db, --json and query options. [`e214746`](https://github.com/RhetTbull/osxphotos/commit/e214746063271e6f9f586286103ed051ada49d85)
|
||||
- Implement from_date and to_date in PhotosDB as well as query and export command. Some refactoring of CLI as well. [`cfa2b4a`](https://github.com/RhetTbull/osxphotos/commit/cfa2b4a828facf0aff5bc19f777457ad776c4a05)
|
||||
- Refactored _query. Still hairy, but less so. [`b9dee49`](https://github.com/RhetTbull/osxphotos/commit/b9dee4995c6d89fadb3d2482374b7098f2ab5ed9)
|
||||
- Updated README.md [`0aff83f`](https://github.com/RhetTbull/osxphotos/commit/0aff83ff21c20e293c0b75bacf2863090a0fb725)
|
||||
- Started adding tests for CLI [`f0b18c3`](https://github.com/RhetTbull/osxphotos/commit/f0b18c3d29b2141d348be0495013c51c072c6251)
|
||||
|
||||
#### [v0.22.0](https://github.com/RhetTbull/osxphotos/compare/v0.21.5...v0.22.0)
|
||||
|
||||
> 18 January 2020
|
||||
|
||||
- Refactored PhotosDB and CLI to require explicity passing the database to avoid non-deterministic behavior when last database can't be found. This may break existing code. [`ede56ff`](https://github.com/RhetTbull/osxphotos/commit/ede56ffc31cf98811b3d4d16e22406ac0eae0315)
|
||||
- Changed get_system_library_path to return None if could not get system library [`646ea4f`](https://github.com/RhetTbull/osxphotos/commit/646ea4f24ca1119b27280af1445e31adcd0690f0)
|
||||
- Updated CHANGELOG.md [`bd20388`](https://github.com/RhetTbull/osxphotos/commit/bd20388778dfa645277029601c63fc9835b7a406)
|
||||
|
||||
#### [v0.21.5](https://github.com/RhetTbull/osxphotos/compare/v0.21.0...v0.21.5)
|
||||
|
||||
> 13 January 2020
|
||||
|
||||
- Fixed search for edited photo in path_edited [`edb31f7`](https://github.com/RhetTbull/osxphotos/commit/edb31f796a76912e6ed8182b691396cf4ec62ffa)
|
||||
- Added tests for live photos [`5473f3b`](https://github.com/RhetTbull/osxphotos/commit/5473f3b3fd745d4772721dfd1ed821ab0660bf72)
|
||||
- Added incloud and iscloudasset for Photos 4 [`e089d13`](https://github.com/RhetTbull/osxphotos/commit/e089d135d3e04320bf98b2c9b11875343e68be04)
|
||||
|
||||
#### [v0.21.0](https://github.com/RhetTbull/osxphotos/compare/v0.20.0...v0.21.0)
|
||||
|
||||
> 4 January 2020
|
||||
|
||||
- Added live photo support for both Photos 4 & 5 [`d5eaff0`](https://github.com/RhetTbull/osxphotos/commit/d5eaff02f2a29a9d105ab72e9a9aeffbc9a3425b)
|
||||
- Added support for burst photos; added export-bursts to CLI [`593983a`](https://github.com/RhetTbull/osxphotos/commit/593983a09940e67fb9347bf345cfd7289465fa0a)
|
||||
- Added live-photo option to CLI query and export [`6f6d37c`](https://github.com/RhetTbull/osxphotos/commit/6f6d37ceacf71a52a2c0216f0ad75afee244946a)
|
||||
|
||||
#### [v0.20.0](https://github.com/RhetTbull/osxphotos/compare/v0.19.0...v0.20.0)
|
||||
|
||||
> 1 January 2020
|
||||
|
||||
- Added support for filtering only movies or photos to CLI; added search for UTI to CLI [`9cd5363`](https://github.com/RhetTbull/osxphotos/commit/9cd5363a800dd85f333219788c661745b2ce88ad)
|
||||
- Added support for bust photos; added export-bursts to CLI [`1136f84`](https://github.com/RhetTbull/osxphotos/commit/1136f84d9b5ea454115ba3d2720625722671e63b)
|
||||
- Temporary fix to filter out unselected burst photos [`a550ba0`](https://github.com/RhetTbull/osxphotos/commit/a550ba00d6ff43a819cb18446e532f10ded81834)
|
||||
|
||||
#### [v0.19.0](https://github.com/RhetTbull/osxphotos/compare/v0.18.0...v0.19.0)
|
||||
|
||||
> 29 December 2019
|
||||
|
||||
- Added support for movies for Photos 5; fixed bugs in ismissing and path [`6f4d129`](https://github.com/RhetTbull/osxphotos/commit/6f4d129f07046c4a34d3d6cf6854c8514a594781)
|
||||
- Added support for movies for Photos 5; fixed bugs in ismissing and path [`b030966`](https://github.com/RhetTbull/osxphotos/commit/b030966051af93be380ff967ac047bf566e5d817)
|
||||
- Initial support for movies [`dbe363e`](https://github.com/RhetTbull/osxphotos/commit/dbe363e4d754253a0405fb1df045677e8780d630)
|
||||
|
||||
#### [v0.18.0](https://github.com/RhetTbull/osxphotos/compare/v0.15.1...v0.18.0)
|
||||
|
||||
> 27 December 2019
|
||||
|
||||
- Restructured entire code base to make it easier to maintain. Closes #16 [`#16`](https://github.com/RhetTbull/osxphotos/issues/16)
|
||||
- Added TOC to README; closes #24 [`#24`](https://github.com/RhetTbull/osxphotos/issues/24)
|
||||
- removed old applescript code and files [`1839593`](https://github.com/RhetTbull/osxphotos/commit/18395933a583314d5d992492713752003852e75c)
|
||||
- Added test cases and documentation for shared photos and shared albums [`6d20e9e`](https://github.com/RhetTbull/osxphotos/commit/6d20e9e36185aa027d82237cadfe3b55614ba96f)
|
||||
- Refactored PhotoInfo to use properties instead of methods--major update [`1ddd90c`](https://github.com/RhetTbull/osxphotos/commit/1ddd90cbdc824afc5df9d2347e730bd9f86350ee)
|
||||
- Moved PhotosDB attributes to properties instead of methods [`d95acdf`](https://github.com/RhetTbull/osxphotos/commit/d95acdf9f8764a1720bcba71a6dad29bf668eaf9)
|
||||
- changed interface for export, prepped for exiftool_json_sidecar [`1fe8859`](https://github.com/RhetTbull/osxphotos/commit/1fe885962e8a9a420e776bdd3dc640ca143224b2)
|
||||
|
||||
#### [v0.15.1](https://github.com/RhetTbull/osxphotos/compare/v0.14.21...v0.15.1)
|
||||
|
||||
> 14 December 2019
|
||||
|
||||
- Added PhotoInfo.export(); closes #10 [`#10`](https://github.com/RhetTbull/osxphotos/issues/10)
|
||||
- refactored private vars in PhotoInfo [`d5a5bd4`](https://github.com/RhetTbull/osxphotos/commit/d5a5bd41b3d3e184d3f9a9d05a32a51fcbe1ef0a)
|
||||
- Updated export example [`bf8aed6`](https://github.com/RhetTbull/osxphotos/commit/bf8aed69cfff61733e4cfd5ed2058bb20e3f5299)
|
||||
|
||||
#### [v0.14.21](https://github.com/RhetTbull/osxphotos/compare/v0.14.8...v0.14.21)
|
||||
|
||||
> 9 December 2019
|
||||
|
||||
- Added list option to cmd_line. Closes #14 [`#14`](https://github.com/RhetTbull/osxphotos/issues/14)
|
||||
- added edited and external_edit to cmd_line and __str__, to_json; closes #12 [`#12`](https://github.com/RhetTbull/osxphotos/issues/12)
|
||||
- Cleaned up logic in cmd_line query(). Closes #17 [`#17`](https://github.com/RhetTbull/osxphotos/issues/17)
|
||||
- Added get_db_path and get_library_path to PhotosDB [`1d006a4`](https://github.com/RhetTbull/osxphotos/commit/1d006a4b50ed58b01c6116734bef5f740655a063)
|
||||
- Updated PhotosDB.__init__() to accept positional or named arg for dbfile and added associated tests [`9118043`](https://github.com/RhetTbull/osxphotos/commit/911804317b98bf485a39b8588c772be14314aa51)
|
||||
- Updated album code in process_database4 and process_database5 to use album uuid [`1cf3e4b`](https://github.com/RhetTbull/osxphotos/commit/1cf3e4b9540c15f8bda2545deb183912bcda40a7)
|
||||
- Updated get_db_version and associated tests [`eb563ad`](https://github.com/RhetTbull/osxphotos/commit/eb563ad29738f29f3514ebfb4747baa2dc5356be)
|
||||
- Added external_edit for Photos 5 [`42baa29`](https://github.com/RhetTbull/osxphotos/commit/42baa29c18fe2ff16e4d684f87ef7a85993898c1)
|
||||
|
||||
#### [v0.14.8](https://github.com/RhetTbull/osxphotos/compare/v0.14.6...v0.14.8)
|
||||
|
||||
> 30 November 2019
|
||||
|
||||
- Added path_edited() for Photos 5, still needs to be added for Photos <= 4.0 [`68eef42`](https://github.com/RhetTbull/osxphotos/commit/68eef42599c737e180d2d0ead936630abd5a8a65)
|
||||
- Fixed path_edited() for Photos 4.0 [`37dfc1e`](https://github.com/RhetTbull/osxphotos/commit/37dfc1e1513c93088fca7cc6def1219d32694468)
|
||||
- cleaned up commented out code [`3dc0943`](https://github.com/RhetTbull/osxphotos/commit/3dc09434535b98a7989c2051a28ecf3ebdc772cc)
|
||||
|
||||
#### [v0.14.6](https://github.com/RhetTbull/osxphotos/compare/v0.14.4...v0.14.6)
|
||||
|
||||
> 28 November 2019
|
||||
|
||||
- Added tests for hidden and favorite to both 14.6 and 15.1 [`51e720d`](https://github.com/RhetTbull/osxphotos/commit/51e720dce9238c2a2b44a7ae956e40f0cd6452d7)
|
||||
- Added location (latitude/longitude), closes issue #2 [`44321da`](https://github.com/RhetTbull/osxphotos/commit/44321da243e374c5239e9bcd28c3515e32e1076a)
|
||||
- cleaned up test code [`b2242da`](https://github.com/RhetTbull/osxphotos/commit/b2242da9b7031f614c73be3fb5446a97f69b1d0d)
|
||||
|
||||
#### [v0.14.4](https://github.com/RhetTbull/osxphotos/compare/v0.14.0...v0.14.4)
|
||||
|
||||
> 25 November 2019
|
||||
|
||||
- Added name and description to cmd_line [`5af2b3e`](https://github.com/RhetTbull/osxphotos/commit/5af2b3e039e5e5a92b858592b8b968568d82e40f)
|
||||
- removed loguru code [`aa73c2f`](https://github.com/RhetTbull/osxphotos/commit/aa73c2f0559de6bcdc521e9345e07898b36795bb)
|
||||
- Added hidden/favorite/missing to cmd_line [`b36b7e7`](https://github.com/RhetTbull/osxphotos/commit/b36b7e7eb2a258f864f34363de4b6d9228ee6090)
|
||||
|
||||
#### [v0.14.0](https://github.com/RhetTbull/osxphotos/compare/v0.12.3...v0.14.0)
|
||||
|
||||
> 24 November 2019
|
||||
|
||||
- added test for 10.15/Catalina [`243492d`](https://github.com/RhetTbull/osxphotos/commit/243492df88409566c46cbc02ca01d509e711bcdd)
|
||||
- moved process_photos to process_photos4 and process_photos5 [`7eff015`](https://github.com/RhetTbull/osxphotos/commit/7eff015439361f3f7be99777d878713afd10c480)
|
||||
- basic Photos 5 info now being read [`a4b5f2a`](https://github.com/RhetTbull/osxphotos/commit/a4b5f2a501d9c98f9609de96757481f323b31ab0)
|
||||
|
||||
#### [v0.12.3](https://github.com/RhetTbull/osxphotos/compare/v0.12.2...v0.12.3)
|
||||
|
||||
> 24 August 2019
|
||||
|
||||
- fixed typo in README [`39ef8dd`](https://github.com/RhetTbull/osxphotos/commit/39ef8ddf3fdf8e9d22566c51783f9b78fab4f439)
|
||||
|
||||
#### [v0.12.2](https://github.com/RhetTbull/osxphotos/compare/v0.10.4-beta...v0.12.2)
|
||||
|
||||
> 24 August 2019
|
||||
|
||||
- Added tests for 10.14.6 [`fb2c12d`](https://github.com/RhetTbull/osxphotos/commit/fb2c12d9818fbec74f947638b1b60a2c3f73effb)
|
||||
- Added support and tests for 10.12 [`58f5283`](https://github.com/RhetTbull/osxphotos/commit/58f52833d62672ed13fcfa16be5d999e75f37e2b)
|
||||
- Added osxphotos command line tool [`0e65ab5`](https://github.com/RhetTbull/osxphotos/commit/0e65ab5452d96dc9913683d90d1fb2c833cd75b8)
|
||||
|
||||
#### [v0.10.4-beta](https://github.com/RhetTbull/osxphotos/compare/v0.10.1-alpha...v0.10.4-beta)
|
||||
|
||||
> 28 July 2019
|
||||
|
||||
- Added test for 10.14 mojave [`af122e9`](https://github.com/RhetTbull/osxphotos/commit/af122e9392d45387e302ebb79b28e045dd3fa61a)
|
||||
- update requirements.txt [`81be373`](https://github.com/RhetTbull/osxphotos/commit/81be373505ad858ae8ef1196ccfb5e6f04bf6bfc)
|
||||
- Updated README, added os & db version tests, updated test library for 10.13 [`a58ac14`](https://github.com/RhetTbull/osxphotos/commit/a58ac149f313ece99ff2d32a8c22e8b8b75eaebc)
|
||||
|
||||
#### v0.10.1-alpha
|
||||
|
||||
> 27 July 2019
|
||||
|
||||
- first commit [`8b61d57`](https://github.com/RhetTbull/osxphotos/commit/8b61d573ed4dbb3fd44b94ee265767b2011fcf90)
|
||||
- Added tests [`3023f56`](https://github.com/RhetTbull/osxphotos/commit/3023f568b73733fb3dfbba4f519a7c2d1995784f)
|
||||
- Updated README, added PhotoInfo.hasadjustments() [`9efa83c`](https://github.com/RhetTbull/osxphotos/commit/9efa83c5cd3f23cf681c459f73a466496c552396)
|
||||
2
MANIFEST.in
Normal file
2
MANIFEST.in
Normal file
@@ -0,0 +1,2 @@
|
||||
include README.md
|
||||
include osxphotos/templates/*
|
||||
@@ -1,18 +1,24 @@
|
||||
import osxphotos
|
||||
import os.path
|
||||
|
||||
|
||||
def main():
|
||||
photosdb = osxphotos.PhotosDB()
|
||||
print(f"db file = {photosdb.get_db_path()}")
|
||||
print(f"db version = {photosdb.get_db_version()}")
|
||||
db = osxphotos.utils.get_system_library_path()
|
||||
if db is None:
|
||||
# Note: get_system_library_path only works on MacOS 10.15+
|
||||
db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
|
||||
|
||||
print(photosdb.keywords())
|
||||
print(photosdb.persons())
|
||||
print(photosdb.albums())
|
||||
photosdb = osxphotos.PhotosDB(db)
|
||||
print(f"db file = {photosdb.db_path}")
|
||||
print(f"db version = {photosdb.db_version}")
|
||||
|
||||
print(photosdb.keywords_as_dict())
|
||||
print(photosdb.persons_as_dict())
|
||||
print(photosdb.albums_as_dict())
|
||||
print(photosdb.keywords)
|
||||
print(photosdb.persons)
|
||||
print(photosdb.albums)
|
||||
|
||||
print(photosdb.keywords_as_dict)
|
||||
print(photosdb.persons_as_dict)
|
||||
print(photosdb.albums_as_dict)
|
||||
|
||||
# find all photos with Keyword = Kids and containing person Katie
|
||||
photos = photosdb.photos(keywords=["Kids"], persons=["Katie"])
|
||||
@@ -29,17 +35,17 @@ def main():
|
||||
photos = photosdb.photos()
|
||||
for p in photos:
|
||||
print(
|
||||
p.uuid(),
|
||||
p.filename(),
|
||||
p.date(),
|
||||
p.description(),
|
||||
p.name(),
|
||||
p.keywords(),
|
||||
p.albums(),
|
||||
p.persons(),
|
||||
p.path(),
|
||||
p.ismissing(),
|
||||
p.hasadjustments(),
|
||||
p.uuid,
|
||||
p.filename,
|
||||
p.date,
|
||||
p.description,
|
||||
p.title,
|
||||
p.keywords,
|
||||
p.albums,
|
||||
p.persons,
|
||||
p.path,
|
||||
p.ismissing,
|
||||
p.hasadjustments,
|
||||
)
|
||||
|
||||
|
||||
|
||||
29
examples/export.py
Normal file
29
examples/export.py
Normal file
@@ -0,0 +1,29 @@
|
||||
""" Export all photos to ~/Desktop/export
|
||||
If file has been edited, export the edited version,
|
||||
otherwise, export the original version """
|
||||
|
||||
import os.path
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
def main():
|
||||
db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
|
||||
photosdb = osxphotos.PhotosDB(db)
|
||||
photos = photosdb.photos()
|
||||
|
||||
export_path = os.path.expanduser("~/Desktop/export")
|
||||
|
||||
for p in photos:
|
||||
if not p.ismissing:
|
||||
if p.hasadjustments:
|
||||
exported = p.export(export_path, edited=True)
|
||||
else:
|
||||
exported = p.export(export_path)
|
||||
print(f"Exported {p.filename} to {exported}")
|
||||
else:
|
||||
print(f"Skipping missing photo: {p.filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
75
examples/export_by_album.py
Normal file
75
examples/export_by_album.py
Normal file
@@ -0,0 +1,75 @@
|
||||
""" 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,
|
||||
)
|
||||
def export(export_path, default_album, library_path):
|
||||
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.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)
|
||||
|
||||
# export the photo
|
||||
if p.hasadjustments:
|
||||
# export edited version
|
||||
exported = p.export(dest_dir, edited=True)
|
||||
edited_name = pathlib.Path(p.path_edited).name
|
||||
click.echo(f"Exported {edited_name} to {exported}")
|
||||
# export unedited version
|
||||
exported = p.export(dest_dir)
|
||||
click.echo(f"Exported {p.filename} to {exported}")
|
||||
else:
|
||||
click.echo(f"Skipping missing photo: {p.filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
export() # pylint: disable=no-value-for-parameter
|
||||
48
examples/photos_repl.py
Executable file
48
examples/photos_repl.py
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3 -i
|
||||
|
||||
# Open an interactive REPL with photosdb and photos defined
|
||||
# as osxphotos.PhotosDB() and PhotosDB.photos respectively
|
||||
# useful for debugging or exploring the Photos database
|
||||
|
||||
# If you run this using python from command line, do so with -i flag:
|
||||
# python3 -i examples/photos_repl.py
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
# click needed since this uses a couple of functions from CLI (__main__.py)
|
||||
import click
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.__main__ import get_photos_db, _list_libraries
|
||||
|
||||
|
||||
def main():
|
||||
db = None
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
db = sys.argv[1]
|
||||
else:
|
||||
db = get_photos_db()
|
||||
|
||||
if db:
|
||||
print("loading database")
|
||||
tic = time.perf_counter()
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
toc = time.perf_counter()
|
||||
print(f"done: took {toc-tic} seconds")
|
||||
return photosdb
|
||||
else:
|
||||
_list_libraries()
|
||||
sys.exit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"osxphotos version: {osxphotos._version.__version__}")
|
||||
photosdb = main()
|
||||
print(f"database version: {photosdb.db_version}")
|
||||
print("getting photos")
|
||||
tic = time.perf_counter()
|
||||
photos = photosdb.photos(images=True, movies=True)
|
||||
toc = time.perf_counter()
|
||||
print(f"found {len(photos)} photos in {toc-tic} seconds")
|
||||
@@ -1,727 +1,16 @@
|
||||
import json
|
||||
import os.path
|
||||
import platform
|
||||
import pprint
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from plistlib import load as plistload
|
||||
from shutil import copyfile
|
||||
import logging
|
||||
|
||||
import CoreFoundation
|
||||
import objc
|
||||
import yaml
|
||||
from Foundation import *
|
||||
from ._version import __version__
|
||||
from .photoinfo import PhotoInfo
|
||||
from .photosdb import PhotosDB
|
||||
from .utils import _set_debug, _debug, _get_logger
|
||||
|
||||
from . import _applescript
|
||||
|
||||
# from loguru import logger
|
||||
|
||||
# TODO: standardize _ and __ as leading char for private variables
|
||||
# TODO: fix use of ''' and """
|
||||
# TODO: find edited photos: see https://github.com/orangeturtle739/photos-export/blob/master/extract_photos.py
|
||||
# TODO: Add test for imageTimeZoneOffsetSeconds = None
|
||||
# TODO: Fix command line so multiple --keyword, etc. are AND (instead of OR as they are in .photos())
|
||||
# Or fix the help text to match behavior
|
||||
# TODO: Add test for __str__ and to_json
|
||||
# TODO: fix docstrings
|
||||
# TODO: fix versions tested to include 10.14.6
|
||||
# TODO: Add special albums and magic albums
|
||||
# TODO: cleanup os.path and pathlib code (import pathlib and also from pathlib import Path)
|
||||
|
||||
# which Photos library database versions have been tested
|
||||
# Photos 2.0 (10.12.6) == 2622
|
||||
# Photos 3.0 (10.13.6) == 3301
|
||||
# Photos 4.0 (10.14.5) == 4016
|
||||
# Photos 4.0 (10.4.6) == 4025
|
||||
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
|
||||
_TESTED_DB_VERSIONS = ["4025", "4016", "3301", "2622"]
|
||||
|
||||
# which major version operating systems have been tested
|
||||
_TESTED_OS_VERSIONS = ["12", "13", "14"]
|
||||
|
||||
_debug = False
|
||||
|
||||
|
||||
def _get_os_version():
|
||||
# returns tuple containing OS version
|
||||
# e.g. 10.13.6 = (10, 13, 6)
|
||||
(ver, major, minor) = platform.mac_ver()[0].split(".")
|
||||
return (ver, major, minor)
|
||||
|
||||
|
||||
def _check_file_exists(filename):
|
||||
# returns true if file exists and is not a directory
|
||||
# otherwise returns false
|
||||
filename = os.path.abspath(filename)
|
||||
return os.path.exists(filename) and not os.path.isdir(filename)
|
||||
|
||||
|
||||
class PhotosDB:
|
||||
def __init__(self, dbfile=None):
|
||||
# Check OS version
|
||||
system = platform.system()
|
||||
(_, major, _) = _get_os_version()
|
||||
# logger.debug(system, major)
|
||||
if system != "Darwin" or (major not in _TESTED_OS_VERSIONS):
|
||||
print(
|
||||
"WARNING: This module has only been tested with MacOS 10."
|
||||
+ f"[{', '.join(_TESTED_OS_VERSIONS)}]: "
|
||||
+ f"you have {system}, OS version: {major}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Dict with information about all photos by uuid
|
||||
self._dbphotos = {}
|
||||
# Dict with information about all persons/photos by uuid
|
||||
self._dbfaces_uuid = {}
|
||||
# Dict with information about all persons/photos by person
|
||||
self._dbfaces_person = {}
|
||||
# Dict with information about all keywords/photos by uuid
|
||||
self._dbkeywords_uuid = {}
|
||||
# Dict with information about all keywords/photos by keyword
|
||||
self._dbkeywords_keyword = {}
|
||||
# Dict with information about all albums/photos by uuid
|
||||
self._dbalbums_uuid = {}
|
||||
# Dict with information about all albums/photos by album
|
||||
self._dbalbums_album = {}
|
||||
# Dict with information about all the volumes/photos by uuid
|
||||
self._dbvolumes = {}
|
||||
|
||||
# logger.debug(dbfile)
|
||||
if dbfile is None:
|
||||
library_path = self.get_photos_library_path()
|
||||
# logger.debug("library_path: " + library_path)
|
||||
# TODO: verify library path not None
|
||||
dbfile = os.path.join(library_path, "database/photos.db")
|
||||
# logger.debug(dbfile)
|
||||
|
||||
# logger.debug(f"filename = {dbfile}")
|
||||
|
||||
# TODO: replace os.path with pathlib
|
||||
# TODO: clean this up -- we'll already know library_path
|
||||
library_path = os.path.dirname(dbfile)
|
||||
(library_path, _) = os.path.split(library_path)
|
||||
masters_path = os.path.join(library_path, "Masters")
|
||||
self._masters_path = masters_path
|
||||
# logger.debug(f"library = {library_path}, masters = {masters_path}")
|
||||
|
||||
if not _check_file_exists(dbfile):
|
||||
sys.exit(f"_dbfile {dbfile} does not exist")
|
||||
|
||||
# logger.info(f"database filename = {dbfile}")
|
||||
|
||||
self._dbfile = dbfile
|
||||
self._setup_applescript()
|
||||
self._process_database()
|
||||
|
||||
def keywords_as_dict(self):
|
||||
# return keywords as dict of keyword, count in reverse sorted order (descending)
|
||||
keywords = {}
|
||||
for k in self._dbkeywords_keyword.keys():
|
||||
keywords[k] = len(self._dbkeywords_keyword[k])
|
||||
keywords = dict(sorted(keywords.items(), key=lambda kv: kv[1], reverse=True))
|
||||
return keywords
|
||||
|
||||
def persons_as_dict(self):
|
||||
# return persons as dict of person, count in reverse sorted order (descending)
|
||||
persons = {}
|
||||
for k in self._dbfaces_person.keys():
|
||||
persons[k] = len(self._dbfaces_person[k])
|
||||
persons = dict(sorted(persons.items(), key=lambda kv: kv[1], reverse=True))
|
||||
return persons
|
||||
|
||||
def albums_as_dict(self):
|
||||
# return albums as dict of albums, count in reverse sorted order (descending)
|
||||
albums = {}
|
||||
for k in self._dbalbums_album.keys():
|
||||
albums[k] = len(self._dbalbums_album[k])
|
||||
albums = dict(sorted(albums.items(), key=lambda kv: kv[1], reverse=True))
|
||||
return albums
|
||||
|
||||
def keywords(self):
|
||||
# return list of keywords found in photos database
|
||||
keywords = self._dbkeywords_keyword.keys()
|
||||
return list(keywords)
|
||||
|
||||
def persons(self):
|
||||
# return persons as dict of person, count in reverse sorted order (descending)
|
||||
persons = self._dbfaces_person.keys()
|
||||
return list(persons)
|
||||
|
||||
def albums(self):
|
||||
# return albums as dict of albums, count in reverse sorted order (descending)
|
||||
albums = self._dbalbums_album.keys()
|
||||
return list(albums)
|
||||
|
||||
# Various AppleScripts we need
|
||||
def _setup_applescript(self):
|
||||
self._scpt_export = ""
|
||||
self._scpt_launch = ""
|
||||
self._scpt_quit = ""
|
||||
|
||||
# Compile apple script that exports one image
|
||||
# self._scpt_export = _applescript.AppleScript('''
|
||||
# on run {arg}
|
||||
# set thepath to "%s"
|
||||
# tell application "Photos"
|
||||
# set theitem to media item id arg
|
||||
# set thelist to {theitem}
|
||||
# export thelist to POSIX file thepath
|
||||
# end tell
|
||||
# end run
|
||||
# ''' % (tmppath))
|
||||
#
|
||||
# Compile apple script that launches Photos.App
|
||||
self._scpt_launch = _applescript.AppleScript(
|
||||
"""
|
||||
on run
|
||||
tell application "Photos"
|
||||
activate
|
||||
end tell
|
||||
end run
|
||||
"""
|
||||
)
|
||||
|
||||
# Compile apple script that quits Photos.App
|
||||
self._scpt_quit = _applescript.AppleScript(
|
||||
"""
|
||||
on run
|
||||
tell application "Photos"
|
||||
quit
|
||||
end tell
|
||||
end run
|
||||
"""
|
||||
)
|
||||
|
||||
def get_db_version(self):
|
||||
# return the database version as stored in LiGlobals table
|
||||
return self.__db_version
|
||||
|
||||
def get_db_path(self):
|
||||
""" return path to the Photos library database PhotosDB was initialized with """
|
||||
return os.path.abspath(self._dbfile)
|
||||
|
||||
def get_photos_library_path(self):
|
||||
# return the path to the Photos library
|
||||
plist_file = Path(
|
||||
str(Path.home())
|
||||
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
|
||||
)
|
||||
if plist_file.is_file():
|
||||
with open(plist_file, "rb") as fp:
|
||||
pl = plistload(fp)
|
||||
else:
|
||||
print("could not find plist file: " + str(plist_file), file=sys.stderr)
|
||||
return None
|
||||
|
||||
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
|
||||
# this is a serialized CFData object
|
||||
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
|
||||
|
||||
if photosurlref != None:
|
||||
# use CFURLCreateByResolvingBookmarkData to de-serialize bookmark data into a CFURLRef
|
||||
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
|
||||
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
|
||||
)
|
||||
|
||||
# the CFURLRef we got is a sruct that python treats as an array
|
||||
# I'd like to pass this to CFURLGetFileSystemRepresentation to get the path but
|
||||
# CFURLGetFileSystemRepresentation barfs when it gets an array from python instead of expected struct
|
||||
# first element is the path string in form:
|
||||
# file:///Users/username/Pictures/Photos%20Library.photoslibrary/
|
||||
photosurlstr = photosurl[0].absoluteString() if photosurl[0] else None
|
||||
|
||||
# now coerce the file URI back into an OS path
|
||||
# surely there must be a better way
|
||||
if photosurlstr is not None:
|
||||
photospath = os.path.normpath(
|
||||
urllib.parse.unquote(urllib.parse.urlparse(photosurlstr).path)
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Could not extract photos URL String from IPXDefaultLibraryURLBookmark",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
return photospath
|
||||
else:
|
||||
print("Could not get path to Photos database", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# TODO: do we need to copy the db-wal write-ahead log file?
|
||||
def _copy_db_file(self, fname):
|
||||
# copies the sqlite database file to a temp file
|
||||
# returns the name of the temp file
|
||||
# required because python's sqlite3 implementation can't read a locked file
|
||||
_, tmp = tempfile.mkstemp(suffix=".db", prefix="photos")
|
||||
# logger.debug("copying " + fname + " to " + tmp)
|
||||
try:
|
||||
copyfile(fname, tmp)
|
||||
except:
|
||||
print("Error copying " + fname + " to " + tmp, file=sys.stderr)
|
||||
raise Exception
|
||||
return tmp
|
||||
|
||||
def _open_sql_file(self, file):
|
||||
fname = file
|
||||
# logger.debug(f"Trying to open database {fname}")
|
||||
try:
|
||||
conn = sqlite3.connect(f"{fname}")
|
||||
c = conn.cursor()
|
||||
except sqlite3.Error as e:
|
||||
print(f"An error occurred: {e.args[0]} {fname}")
|
||||
sys.exit(3)
|
||||
# logger.debug("SQLite database is open")
|
||||
return (conn, c)
|
||||
|
||||
def _process_database(self):
|
||||
global _debug
|
||||
|
||||
fname = self._dbfile
|
||||
|
||||
# Epoch is Jan 1, 2001
|
||||
td = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
|
||||
|
||||
# Ensure Photos.App is not running
|
||||
self._scpt_quit.run()
|
||||
|
||||
tmp_db = self._copy_db_file(fname)
|
||||
(conn, c) = self._open_sql_file(tmp_db)
|
||||
# logger.debug("Have connection with database")
|
||||
|
||||
# get database version
|
||||
c.execute(
|
||||
"SELECT value from LiGlobals where LiGlobals.keyPath is 'libraryVersion'"
|
||||
)
|
||||
for ver in c:
|
||||
self.__db_version = ver[0]
|
||||
break # TODO: is there a more pythonic way to do get the first element from cursor?
|
||||
|
||||
if self.__db_version not in _TESTED_DB_VERSIONS:
|
||||
print(
|
||||
f"WARNING: Only tested on database versions [{', '.join(_TESTED_DB_VERSIONS)}]"
|
||||
+ f" You have database version={self.__db_version} which has not been tested"
|
||||
)
|
||||
|
||||
# Look for all combinations of persons and pictures
|
||||
# logger.debug("Getting information about persons")
|
||||
|
||||
i = 0
|
||||
c.execute(
|
||||
"select count(*) from RKFace, RKPerson, RKVersion where RKFace.personID = RKperson.modelID "
|
||||
+ "and RKFace.imageModelId = RKVersion.modelId and RKVersion.isInTrash = 0"
|
||||
)
|
||||
# init_pbar_status("Faces", c.fetchone()[0])
|
||||
# c.execute("select RKPerson.name, RKFace.imageID from RKFace, RKPerson where RKFace.personID = RKperson.modelID")
|
||||
c.execute(
|
||||
"select RKPerson.name, RKVersion.uuid from RKFace, RKPerson, RKVersion, RKMaster "
|
||||
+ "where RKFace.personID = RKperson.modelID and RKVersion.modelId = RKFace.ImageModelId "
|
||||
+ "and RKVersion.type = 2 and RKVersion.masterUuid = RKMaster.uuid and "
|
||||
+ "RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
|
||||
)
|
||||
for person in c:
|
||||
if person[0] == None:
|
||||
# logger.debug(f"skipping person = None {person[1]}")
|
||||
continue
|
||||
if not person[1] in self._dbfaces_uuid:
|
||||
self._dbfaces_uuid[person[1]] = []
|
||||
if not person[0] in self._dbfaces_person:
|
||||
self._dbfaces_person[person[0]] = []
|
||||
self._dbfaces_uuid[person[1]].append(person[0])
|
||||
self._dbfaces_person[person[0]].append(person[1])
|
||||
# set_pbar_status(i)
|
||||
i = i + 1
|
||||
# logger.debug("Finished walking through persons")
|
||||
# close_pbar_status()
|
||||
|
||||
# logger.debug("Getting information about albums")
|
||||
i = 0
|
||||
c.execute(
|
||||
"select count(*) from RKAlbum, RKVersion, RKAlbumVersion where "
|
||||
+ "RKAlbum.modelID = RKAlbumVersion.albumId and "
|
||||
+ "RKAlbumVersion.versionID = RKVersion.modelId and "
|
||||
+ "RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
|
||||
)
|
||||
# init_pbar_status("Albums", c.fetchone()[0])
|
||||
# c.execute("select RKPerson.name, RKFace.imageID from RKFace, RKPerson where RKFace.personID = RKperson.modelID")
|
||||
c.execute(
|
||||
"select RKAlbum.name, RKVersion.uuid from RKAlbum, RKVersion, RKAlbumVersion "
|
||||
+ "where RKAlbum.modelID = RKAlbumVersion.albumId and "
|
||||
+ "RKAlbumVersion.versionID = RKVersion.modelId and RKVersion.type = 2 and "
|
||||
+ "RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
|
||||
)
|
||||
for album in c:
|
||||
# store by uuid in _dbalbums_uuid and by album in _dbalbums_album
|
||||
if not album[1] in self._dbalbums_uuid:
|
||||
self._dbalbums_uuid[album[1]] = []
|
||||
if not album[0] in self._dbalbums_album:
|
||||
self._dbalbums_album[album[0]] = []
|
||||
self._dbalbums_uuid[album[1]].append(album[0])
|
||||
self._dbalbums_album[album[0]].append(album[1])
|
||||
# logger.debug(f"{album[1]} {album[0]}")
|
||||
# set_pbar_status(i)
|
||||
i = i + 1
|
||||
# logger.debug("Finished walking through albums")
|
||||
# close_pbar_status()
|
||||
|
||||
# logger.debug("Getting information about keywords")
|
||||
c.execute(
|
||||
"select count(*) from RKKeyword, RKKeywordForVersion,RKVersion, RKMaster "
|
||||
+ "where RKKeyword.modelId = RKKeyWordForVersion.keywordID and "
|
||||
+ "RKVersion.modelID = RKKeywordForVersion.versionID and RKMaster.uuid = "
|
||||
+ "RKVersion.masterUuid and RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
|
||||
)
|
||||
# init_pbar_status("Keywords", c.fetchone()[0])
|
||||
c.execute(
|
||||
"select RKKeyword.name, RKVersion.uuid, RKMaster.uuid from "
|
||||
+ "RKKeyword, RKKeywordForVersion, RKVersion, RKMaster "
|
||||
+ "where RKKeyword.modelId = RKKeyWordForVersion.keywordID and "
|
||||
+ "RKVersion.modelID = RKKeywordForVersion.versionID "
|
||||
+ "and RKMaster.uuid = RKVersion.masterUuid and RKVersion.type = 2 "
|
||||
+ "and RKVersion.filename not like '%.pdf' and RKVersion.isInTrash = 0"
|
||||
)
|
||||
i = 0
|
||||
for keyword in c:
|
||||
if not keyword[1] in self._dbkeywords_uuid:
|
||||
self._dbkeywords_uuid[keyword[1]] = []
|
||||
if not keyword[0] in self._dbkeywords_keyword:
|
||||
self._dbkeywords_keyword[keyword[0]] = []
|
||||
self._dbkeywords_uuid[keyword[1]].append(keyword[0])
|
||||
self._dbkeywords_keyword[keyword[0]].append(keyword[1])
|
||||
# logger.debug(f"{keyword[1]} {keyword[0]}")
|
||||
# set_pbar_status(i)
|
||||
i = i + 1
|
||||
# logger.debug("Finished walking through keywords")
|
||||
# close_pbar_status()
|
||||
|
||||
# logger.debug("Getting information about volumes")
|
||||
c.execute("select count(*) from RKVolume")
|
||||
# init_pbar_status("Volumes", c.fetchone()[0])
|
||||
c.execute("select RKVolume.modelId, RKVolume.name from RKVolume")
|
||||
i = 0
|
||||
for vol in c:
|
||||
self._dbvolumes[vol[0]] = vol[1]
|
||||
# logger.debug(f"{vol[0]} {vol[1]}")
|
||||
# set_pbar_status(i)
|
||||
i = i + 1
|
||||
# logger.debug("Finished walking through volumes")
|
||||
# close_pbar_status()
|
||||
|
||||
# logger.debug("Getting information about photos")
|
||||
c.execute(
|
||||
"select count(*) from RKVersion, RKMaster where RKVersion.isInTrash = 0 and "
|
||||
+ "RKVersion.type = 2 and RKVersion.masterUuid = RKMaster.uuid and "
|
||||
+ "RKVersion.filename not like '%.pdf'"
|
||||
)
|
||||
# init_pbar_status("Photos", c.fetchone()[0])
|
||||
c.execute(
|
||||
"select RKVersion.uuid, RKVersion.modelId, RKVersion.masterUuid, RKVersion.filename, "
|
||||
+ "RKVersion.lastmodifieddate, RKVersion.imageDate, RKVersion.mainRating, "
|
||||
+ "RKVersion.hasAdjustments, RKVersion.hasKeywords, RKVersion.imageTimeZoneOffsetSeconds, "
|
||||
+ "RKMaster.volumeId, RKMaster.imagePath, RKVersion.extendedDescription, RKVersion.name, "
|
||||
+ "RKMaster.isMissing "
|
||||
+ "from RKVersion, RKMaster where RKVersion.isInTrash = 0 and RKVersion.type = 2 and "
|
||||
+ "RKVersion.masterUuid = RKMaster.uuid and RKVersion.filename not like '%.pdf'"
|
||||
)
|
||||
i = 0
|
||||
for row in c:
|
||||
# set_pbar_status(i)
|
||||
i = i + 1
|
||||
uuid = row[0]
|
||||
if _debug:
|
||||
print(f"i = {i:d}, uuid = '{uuid}, master = '{row[2]}")
|
||||
self._dbphotos[uuid] = {}
|
||||
self._dbphotos[uuid]["modelID"] = row[1]
|
||||
self._dbphotos[uuid]["masterUuid"] = row[2]
|
||||
self._dbphotos[uuid]["filename"] = row[3]
|
||||
try:
|
||||
self._dbphotos[uuid]["lastmodifieddate"] = datetime.fromtimestamp(
|
||||
row[4] + td
|
||||
)
|
||||
except:
|
||||
self._dbphotos[uuid]["lastmodifieddate"] = datetime.fromtimestamp(
|
||||
row[5] + td
|
||||
)
|
||||
|
||||
self._dbphotos[uuid]["imageDate"] = datetime.fromtimestamp(
|
||||
row[5] + td
|
||||
) # - row[9], timezone.utc)
|
||||
self._dbphotos[uuid]["mainRating"] = row[6]
|
||||
self._dbphotos[uuid]["hasAdjustments"] = row[7]
|
||||
self._dbphotos[uuid]["hasKeywords"] = row[8]
|
||||
self._dbphotos[uuid]["imageTimeZoneOffsetSeconds"] = row[9]
|
||||
self._dbphotos[uuid]["volumeId"] = row[10]
|
||||
self._dbphotos[uuid]["imagePath"] = row[11]
|
||||
self._dbphotos[uuid]["extendedDescription"] = row[12]
|
||||
self._dbphotos[uuid]["name"] = row[13]
|
||||
self._dbphotos[uuid]["isMissing"] = row[14]
|
||||
# logger.debug(
|
||||
# "Fetching data for photo %d %s %s %s %s %s: %s"
|
||||
# % (
|
||||
# i,
|
||||
# uuid,
|
||||
# self._dbphotos[uuid]["masterUuid"],
|
||||
# self._dbphotos[uuid]["volumeId"],
|
||||
# self._dbphotos[uuid]["filename"],
|
||||
# self._dbphotos[uuid]["extendedDescription"],
|
||||
# self._dbphotos[uuid]["imageDate"],
|
||||
# )
|
||||
# )
|
||||
|
||||
# close_pbar_status()
|
||||
conn.close()
|
||||
|
||||
# add faces and keywords to photo data
|
||||
for uuid in self._dbphotos:
|
||||
# keywords
|
||||
if self._dbphotos[uuid]["hasKeywords"] == 1:
|
||||
self._dbphotos[uuid]["keywords"] = self._dbkeywords_uuid[uuid]
|
||||
else:
|
||||
self._dbphotos[uuid]["keywords"] = []
|
||||
|
||||
if uuid in self._dbfaces_uuid:
|
||||
self._dbphotos[uuid]["hasPersons"] = 1
|
||||
self._dbphotos[uuid]["persons"] = self._dbfaces_uuid[uuid]
|
||||
else:
|
||||
self._dbphotos[uuid]["hasPersons"] = 0
|
||||
self._dbphotos[uuid]["persons"] = []
|
||||
|
||||
if uuid in self._dbalbums_uuid:
|
||||
self._dbphotos[uuid]["albums"] = self._dbalbums_uuid[uuid]
|
||||
self._dbphotos[uuid]["hasAlbums"] = 1
|
||||
else:
|
||||
self._dbphotos[uuid]["albums"] = []
|
||||
self._dbphotos[uuid]["hasAlbums"] = 0
|
||||
|
||||
if self._dbphotos[uuid]["volumeId"] is not None:
|
||||
self._dbphotos[uuid]["volume"] = self._dbvolumes[
|
||||
self._dbphotos[uuid]["volumeId"]
|
||||
]
|
||||
else:
|
||||
self._dbphotos[uuid]["volume"] = None
|
||||
|
||||
# remove temporary copy of the database
|
||||
try:
|
||||
# logger.info("Removing temporary database file: " + tmp_db)
|
||||
os.remove(tmp_db)
|
||||
except:
|
||||
print("Could not remove temporary database: " + tmp_db, file=sys.stderr)
|
||||
|
||||
if _debug:
|
||||
pp = pprint.PrettyPrinter(indent=4)
|
||||
print("Faces:")
|
||||
pp.pprint(self._dbfaces_uuid)
|
||||
|
||||
print("Keywords by uuid:")
|
||||
pp.pprint(self._dbkeywords_uuid)
|
||||
|
||||
print("Keywords by keyword:")
|
||||
pp.pprint(self._dbkeywords_keyword)
|
||||
|
||||
print("Albums by uuid:")
|
||||
pp.pprint(self._dbalbums_uuid)
|
||||
|
||||
print("Albums by album:")
|
||||
pp.pprint(self._dbalbums_album)
|
||||
|
||||
print("Volumes:")
|
||||
pp.pprint(self._dbvolumes)
|
||||
|
||||
print("Photos:")
|
||||
pp.pprint(self._dbphotos)
|
||||
|
||||
# logger.debug(f"processed {len(self._dbphotos)} photos")
|
||||
|
||||
"""
|
||||
Return a list of PhotoInfo objects
|
||||
If called with no args, returns the entire database of photos
|
||||
If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
|
||||
If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
|
||||
"""
|
||||
|
||||
def photos(self, keywords=[], uuid=[], persons=[], albums=[]):
|
||||
# TODO: remove the logger code then dangling else: pass statements
|
||||
photos_sets = [] # list of photo sets to perform intersection of
|
||||
if not keywords and not uuid and not persons and not albums:
|
||||
# return all the photos
|
||||
# append keys of all photos as a single set to photos_sets
|
||||
# logger.debug("return all photos")
|
||||
photos_sets.append(set(self._dbphotos.keys()))
|
||||
else:
|
||||
if albums:
|
||||
for album in albums:
|
||||
# logger.info(f"album={album}")
|
||||
if album in self._dbalbums_album:
|
||||
# logger.info(f"processing album {album}:")
|
||||
photos_sets.append(set(self._dbalbums_album[album]))
|
||||
else:
|
||||
# logger.debug(f"Could not find album '{album}' in database")
|
||||
pass
|
||||
|
||||
if uuid:
|
||||
for u in uuid:
|
||||
# logger.info(f"uuid={u}")
|
||||
if u in self._dbphotos:
|
||||
# logger.info(f"processing uuid {u}:")
|
||||
photos_sets.append(set([u]))
|
||||
else:
|
||||
# logger.debug(f"Could not find uuid '{u}' in database")
|
||||
pass
|
||||
|
||||
if keywords:
|
||||
for keyword in keywords:
|
||||
# logger.info(f"keyword={keyword}")
|
||||
if keyword in self._dbkeywords_keyword:
|
||||
# logger.info(f"processing keyword {keyword}:")
|
||||
photos_sets.append(set(self._dbkeywords_keyword[keyword]))
|
||||
# logger.debug(f"photos_sets {photos_sets}")
|
||||
else:
|
||||
# logger.debug(f"Could not find keyword '{keyword}' in database")
|
||||
pass
|
||||
|
||||
if persons:
|
||||
for person in persons:
|
||||
# logger.info(f"person={person}")
|
||||
if person in self._dbfaces_person:
|
||||
# logger.info(f"processing person {person}:")
|
||||
photos_sets.append(set(self._dbfaces_person[person]))
|
||||
else:
|
||||
# logger.debug(f"Could not find person '{person}' in database")
|
||||
pass
|
||||
|
||||
photoinfo = []
|
||||
if photos_sets: # found some photos
|
||||
# get the intersection of each argument/search criteria
|
||||
for p in set.intersection(*photos_sets):
|
||||
# logger.debug(f"p={p}")
|
||||
info = PhotoInfo(db=self, uuid=p, info=self._dbphotos[p])
|
||||
# logger.debug(f"info={info}")
|
||||
photoinfo.append(info)
|
||||
return photoinfo
|
||||
|
||||
def __repr__(self):
|
||||
return f"osxphotos.PhotosDB(dbfile='{self.get_db_path()}')"
|
||||
|
||||
|
||||
"""
|
||||
Info about a specific photo, contains all the details we know about the photo
|
||||
including keywords, persons, albums, uuid, path, etc.
|
||||
"""
|
||||
|
||||
|
||||
class PhotoInfo:
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self.__uuid = uuid
|
||||
self.__info = info
|
||||
self.__db = db
|
||||
|
||||
def filename(self):
|
||||
return self.__info["filename"]
|
||||
|
||||
def date(self):
|
||||
""" image creation date as timezone aware datetime object """
|
||||
imagedate = self.__info["imageDate"]
|
||||
delta = timedelta(seconds=self.__info["imageTimeZoneOffsetSeconds"])
|
||||
tz = timezone(delta)
|
||||
imagedate_utc = imagedate.astimezone(tz=tz)
|
||||
return imagedate_utc
|
||||
|
||||
def tzoffset(self):
|
||||
""" timezone offset from UTC in seconds """
|
||||
return self.__info["imageTimeZoneOffsetSeconds"]
|
||||
|
||||
def path(self):
|
||||
photopath = ""
|
||||
|
||||
vol = self.__info["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join("/Volumes", vol, self.__info["imagePath"])
|
||||
else:
|
||||
photopath = os.path.join(self.__db._masters_path, self.__info["imagePath"])
|
||||
|
||||
if self.__info["isMissing"] == 1:
|
||||
# logger.warning(
|
||||
# f"Skipping photo, not yet downloaded from iCloud: {photopath}"
|
||||
# )
|
||||
# logger.debug(self.__info)
|
||||
photopath = None # path would be meaningless until downloaded
|
||||
# TODO: Is there a way to use applescript to force the download in this
|
||||
|
||||
return photopath
|
||||
|
||||
def description(self):
|
||||
return self.__info["extendedDescription"]
|
||||
|
||||
def persons(self):
|
||||
return self.__info["persons"]
|
||||
|
||||
def albums(self):
|
||||
return self.__info["albums"]
|
||||
|
||||
def keywords(self):
|
||||
return self.__info["keywords"]
|
||||
|
||||
def name(self):
|
||||
return self.__info["name"]
|
||||
|
||||
def uuid(self):
|
||||
return self.__uuid
|
||||
|
||||
def ismissing(self):
|
||||
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
|
||||
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
|
||||
do not immediately get written to disk. In particular, I've noticed that downloading
|
||||
an image from the cloud does not force the database to be updated until something else
|
||||
e.g. an edit, keyword, etc. occurs forcing a database synch
|
||||
The exact process / timing is a mystery to be but be aware that if some photos were recently
|
||||
downloaded from cloud to local storate their status in the database might still show
|
||||
isMissing = 1
|
||||
"""
|
||||
return True if self.__info["isMissing"] == 1 else False
|
||||
|
||||
def hasadjustments(self):
|
||||
return True if self.__info["hasAdjustments"] == 1 else False
|
||||
|
||||
def __repr__(self):
|
||||
return f"osxphotos.PhotoInfo(db={self.__db}, uuid='{self.__uuid}', info={self.__info})"
|
||||
|
||||
def __str__(self):
|
||||
info = {
|
||||
"uuid": self.uuid(),
|
||||
"filename": self.filename(),
|
||||
"date": str(self.date()),
|
||||
"description": self.description(),
|
||||
"name": self.name(),
|
||||
"keywords": self.keywords(),
|
||||
"albums": self.albums(),
|
||||
"persons": self.persons(),
|
||||
"path": self.path(),
|
||||
"ismissing": self.ismissing(),
|
||||
"hasadjustments": self.hasadjustments(),
|
||||
}
|
||||
return yaml.dump(info, sort_keys=False)
|
||||
|
||||
def to_json(self):
|
||||
""" return JSON representation """
|
||||
pic = {
|
||||
"uuid": self.uuid(),
|
||||
"filename": self.filename(),
|
||||
"date": str(self.date()),
|
||||
"description": self.description(),
|
||||
"name": self.name(),
|
||||
"keywords": self.keywords(),
|
||||
"albums": self.albums(),
|
||||
"persons": self.persons(),
|
||||
"path": self.path(),
|
||||
"ismissing": self.ismissing(),
|
||||
"hasadjustments": self.hasadjustments(),
|
||||
}
|
||||
return json.dumps(pic)
|
||||
|
||||
# compare two PhotoInfo objects for equality
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, self.__class__):
|
||||
return self.__dict__ == other.__dict__
|
||||
else:
|
||||
return False
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
1482
osxphotos/__main__.py
Normal file
1482
osxphotos/__main__.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,207 +0,0 @@
|
||||
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
|
||||
"""
|
||||
This code is from py-applescript, a public domain package available at:
|
||||
https://github.com/rdhyee/py-applescript
|
||||
|
||||
I've included the whole thing here for simplicity as there is more than one
|
||||
applescript packge on PyPi so there's ambiguity as to which one "import applescript"
|
||||
would use if user had installed another library.
|
||||
|
||||
This package is used instead of the others because it uses a native PyObjC
|
||||
bridge and is thus much faster than others which use osascript.
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from Foundation import (
|
||||
NSAppleScript,
|
||||
NSAppleEventDescriptor,
|
||||
NSURL,
|
||||
NSAppleScriptErrorMessage,
|
||||
NSAppleScriptErrorBriefMessage,
|
||||
NSAppleScriptErrorNumber,
|
||||
NSAppleScriptErrorAppName,
|
||||
NSAppleScriptErrorRange,
|
||||
)
|
||||
|
||||
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
|
||||
from . import kae
|
||||
|
||||
__all__ = ["AppleScript", "ScriptError", "AEType", "AEEnum", "kMissingValue", "kae"]
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
class AppleScript:
|
||||
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
_codecs = Codecs()
|
||||
|
||||
def __init__(self, source=None, path=None):
|
||||
"""
|
||||
source : str | None -- AppleScript source code
|
||||
path : str | None -- full path to .scpt/.applescript file
|
||||
|
||||
Notes:
|
||||
|
||||
- Either the path or the source argument must be provided.
|
||||
|
||||
- If the script cannot be read/compiled, a ScriptError is raised.
|
||||
"""
|
||||
if path:
|
||||
url = NSURL.fileURLWithPath_(path)
|
||||
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(
|
||||
url, None
|
||||
)
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
elif source:
|
||||
self._script = NSAppleScript.alloc().initWithSource_(source)
|
||||
else:
|
||||
raise ValueError("Missing source or path argument.")
|
||||
if not self._script.isCompiled():
|
||||
errorinfo = self._script.compileAndReturnError_(None)[1]
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
s = self.source
|
||||
return "AppleScript({})".format(
|
||||
repr(s) if len(s) < 100 else "{}...{}".format(repr(s)[:80], repr(s)[-17:])
|
||||
)
|
||||
|
||||
##
|
||||
|
||||
def _newevent(self, suite, code, args):
|
||||
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
|
||||
fourcharcode(suite),
|
||||
fourcharcode(code),
|
||||
NSAppleEventDescriptor.nullDescriptor(),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
evt.setDescriptor_forKeyword_(
|
||||
self._codecs.pack(args), fourcharcode(kae.keyDirectObject)
|
||||
)
|
||||
return evt
|
||||
|
||||
def _unpackresult(self, result, errorinfo):
|
||||
if not result:
|
||||
raise ScriptError(errorinfo)
|
||||
return self._codecs.unpack(result)
|
||||
|
||||
##
|
||||
|
||||
source = property(
|
||||
lambda self: str(self._script.source()), doc="str -- the script's source code"
|
||||
)
|
||||
|
||||
def run(self, *args):
|
||||
""" Run the script, optionally passing arguments to its run handler.
|
||||
|
||||
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The run handler must be explicitly declared in order to pass arguments.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
if args:
|
||||
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
else:
|
||||
return self._unpackresult(*self._script.executeAndReturnError_(None))
|
||||
|
||||
def call(self, name, *args):
|
||||
""" Call the specified user-defined handler.
|
||||
|
||||
name : str -- the handler's name (case-sensitive)
|
||||
args : anything -- arguments to pass to script, if any; see documentation for supported types
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
evt = self._newevent(
|
||||
kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args
|
||||
)
|
||||
evt.setDescriptor_forKeyword_(
|
||||
NSAppleEventDescriptor.descriptorWithString_(name),
|
||||
fourcharcode(kae.keyASSubroutineName),
|
||||
)
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class ScriptError(Exception):
|
||||
""" Indicates an AppleScript compilation/execution error. """
|
||||
|
||||
def __init__(self, errorinfo):
|
||||
self._errorinfo = dict(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
return "ScriptError({})".format(self._errorinfo)
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
""" str -- the error message """
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
|
||||
if not msg:
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, "Script Error")
|
||||
return msg
|
||||
|
||||
number = property(
|
||||
lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
|
||||
doc="int | None -- the error number, if given",
|
||||
)
|
||||
|
||||
appname = property(
|
||||
lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
|
||||
doc="str | None -- the name of the application that reported the error, where relevant",
|
||||
)
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
|
||||
range = self._errorinfo.get(NSAppleScriptErrorRange)
|
||||
if range:
|
||||
start = range.rangeValue().location
|
||||
end = start + range.rangeValue().length
|
||||
return (start, end)
|
||||
else:
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
msg = self.message
|
||||
for s, v in [
|
||||
(" ({})", self.number),
|
||||
(" app={!r}", self.appname),
|
||||
(" range={0[0]}-{0[1]}", self.range),
|
||||
]:
|
||||
if v is not None:
|
||||
msg += s.format(v)
|
||||
return (
|
||||
msg.encode("ascii", "replace") if sys.version_info.major < 3 else msg
|
||||
) # 2.7 compatibility
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
kMissingValue = AEType(kae.cMissingValue) # convenience constant
|
||||
162
osxphotos/_applescript/__init__.py
Normal file
162
osxphotos/_applescript/__init__.py
Normal file
@@ -0,0 +1,162 @@
|
||||
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
|
||||
|
||||
import sys
|
||||
|
||||
from Foundation import NSAppleScript, NSAppleEventDescriptor, NSURL, \
|
||||
NSAppleScriptErrorMessage, NSAppleScriptErrorBriefMessage, \
|
||||
NSAppleScriptErrorNumber, NSAppleScriptErrorAppName, NSAppleScriptErrorRange
|
||||
|
||||
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
|
||||
from . import kae
|
||||
|
||||
__all__ = ['AppleScript', 'ScriptError', 'AEType', 'AEEnum', 'kMissingValue', 'kae']
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
class AppleScript:
|
||||
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
_codecs = Codecs()
|
||||
|
||||
def __init__(self, source=None, path=None):
|
||||
"""
|
||||
source : str | None -- AppleScript source code
|
||||
path : str | None -- full path to .scpt/.applescript file
|
||||
|
||||
Notes:
|
||||
|
||||
- Either the path or the source argument must be provided.
|
||||
|
||||
- If the script cannot be read/compiled, a ScriptError is raised.
|
||||
"""
|
||||
if path:
|
||||
url = NSURL.fileURLWithPath_(path)
|
||||
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(url, None)
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
elif source:
|
||||
self._script = NSAppleScript.alloc().initWithSource_(source)
|
||||
else:
|
||||
raise ValueError("Missing source or path argument.")
|
||||
if not self._script.isCompiled():
|
||||
errorinfo = self._script.compileAndReturnError_(None)[1]
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
s = self.source
|
||||
return 'AppleScript({})'.format(repr(s) if len(s) < 100 else '{}...{}'.format(repr(s)[:80], repr(s)[-17:]))
|
||||
|
||||
##
|
||||
|
||||
def _newevent(self, suite, code, args):
|
||||
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
|
||||
fourcharcode(suite), fourcharcode(code), NSAppleEventDescriptor.nullDescriptor(), 0, 0)
|
||||
evt.setDescriptor_forKeyword_(self._codecs.pack(args), fourcharcode(kae.keyDirectObject))
|
||||
return evt
|
||||
|
||||
def _unpackresult(self, result, errorinfo):
|
||||
if not result:
|
||||
raise ScriptError(errorinfo)
|
||||
return self._codecs.unpack(result)
|
||||
|
||||
##
|
||||
|
||||
source = property(lambda self: str(self._script.source()), doc="str -- the script's source code")
|
||||
|
||||
def run(self, *args):
|
||||
""" Run the script, optionally passing arguments to its run handler.
|
||||
|
||||
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The run handler must be explicitly declared in order to pass arguments.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
if args:
|
||||
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
else:
|
||||
return self._unpackresult(*self._script.executeAndReturnError_(None))
|
||||
|
||||
def call(self, name, *args):
|
||||
""" Call the specified user-defined handler.
|
||||
|
||||
name : str -- the handler's name (case-sensitive)
|
||||
args : anything -- arguments to pass to script, if any; see documentation for supported types
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
evt = self._newevent(kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args)
|
||||
evt.setDescriptor_forKeyword_(NSAppleEventDescriptor.descriptorWithString_(name),
|
||||
fourcharcode(kae.keyASSubroutineName))
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class ScriptError(Exception):
|
||||
""" Indicates an AppleScript compilation/execution error. """
|
||||
|
||||
def __init__(self, errorinfo):
|
||||
self._errorinfo = dict(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
return 'ScriptError({})'.format(self._errorinfo)
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
""" str -- the error message """
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
|
||||
if not msg:
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, 'Script Error')
|
||||
return msg
|
||||
|
||||
number = property(lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
|
||||
doc="int | None -- the error number, if given")
|
||||
|
||||
appname = property(lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
|
||||
doc="str | None -- the name of the application that reported the error, where relevant")
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
|
||||
range = self._errorinfo.get(NSAppleScriptErrorRange)
|
||||
if range:
|
||||
start = range.rangeValue().location
|
||||
end = start + range.rangeValue().length
|
||||
return (start, end)
|
||||
else:
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
msg = self.message
|
||||
for s, v in [(' ({})', self.number), (' app={!r}', self.appname), (' range={0[0]}-{0[1]}', self.range)]:
|
||||
if v is not None:
|
||||
msg += s.format(v)
|
||||
return msg.encode('ascii', 'replace') if sys.version_info.major < 3 else msg # 2.7 compatibility
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
kMissingValue = AEType(kae.cMissingValue) # convenience constant
|
||||
|
||||
269
osxphotos/_applescript/aecodecs.py
Normal file
269
osxphotos/_applescript/aecodecs.py
Normal file
@@ -0,0 +1,269 @@
|
||||
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
|
||||
|
||||
import datetime, struct, sys
|
||||
|
||||
from Foundation import NSAppleEventDescriptor, NSURL
|
||||
|
||||
from . import kae
|
||||
|
||||
|
||||
__all__ = ['Codecs', 'AEType', 'AEEnum']
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
def fourcharcode(code):
|
||||
""" Convert four-char code for use in NSAppleEventDescriptor methods.
|
||||
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
Result : int -- OSType, e.g. 1970567284
|
||||
"""
|
||||
return struct.unpack('>I', code)[0]
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class Codecs:
|
||||
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
|
||||
|
||||
kMacEpoch = datetime.datetime(1904, 1, 1)
|
||||
kUSRF = fourcharcode(kae.keyASUserRecordFields)
|
||||
|
||||
def __init__(self):
|
||||
# Clients may add/remove/replace encoder and decoder items:
|
||||
self.encoders = {
|
||||
NSAppleEventDescriptor.class__(): self.packdesc,
|
||||
type(None): self.packnone,
|
||||
bool: self.packbool,
|
||||
int: self.packint,
|
||||
float: self.packfloat,
|
||||
bytes: self.packbytes,
|
||||
str: self.packstr,
|
||||
list: self.packlist,
|
||||
tuple: self.packlist,
|
||||
dict: self.packdict,
|
||||
datetime.datetime: self.packdatetime,
|
||||
AEType: self.packtype,
|
||||
AEEnum: self.packenum,
|
||||
}
|
||||
if sys.version_info.major < 3: # 2.7 compatibility
|
||||
self.encoders[unicode] = self.packstr
|
||||
|
||||
self.decoders = {fourcharcode(k): v for k, v in {
|
||||
kae.typeNull: self.unpacknull,
|
||||
kae.typeBoolean: self.unpackboolean,
|
||||
kae.typeFalse: self.unpackboolean,
|
||||
kae.typeTrue: self.unpackboolean,
|
||||
kae.typeSInt32: self.unpacksint32,
|
||||
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
|
||||
kae.typeUTF8Text: self.unpackunicodetext,
|
||||
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
|
||||
kae.typeUnicodeText: self.unpackunicodetext,
|
||||
kae.typeLongDateTime: self.unpacklongdatetime,
|
||||
kae.typeAEList: self.unpackaelist,
|
||||
kae.typeAERecord: self.unpackaerecord,
|
||||
kae.typeAlias: self.unpackfile,
|
||||
kae.typeFSS: self.unpackfile,
|
||||
kae.typeFSRef: self.unpackfile,
|
||||
kae.typeFileURL: self.unpackfile,
|
||||
kae.typeType: self.unpacktype,
|
||||
kae.typeEnumeration: self.unpackenumeration,
|
||||
}.items()}
|
||||
|
||||
def pack(self, data):
|
||||
"""Pack Python data.
|
||||
data : anything -- a Python value
|
||||
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
|
||||
"""
|
||||
try:
|
||||
return self.encoders[data.__class__](data) # quick lookup by type/class
|
||||
except (KeyError, AttributeError) as e:
|
||||
for type, encoder in self.encoders.items(): # slower but more thorough lookup that can handle subtypes/subclasses
|
||||
if isinstance(data, type):
|
||||
return encoder(data)
|
||||
raise TypeError("Can't pack data into an AEDesc (unsupported type): {!r}".format(data))
|
||||
|
||||
def unpack(self, desc):
|
||||
"""Unpack an Apple event descriptor.
|
||||
desc : NSAppleEventDescriptor
|
||||
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
|
||||
"""
|
||||
decoder = self.decoders.get(desc.descriptorType())
|
||||
# unpack known type
|
||||
if decoder:
|
||||
return decoder(desc)
|
||||
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
|
||||
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
|
||||
if rec:
|
||||
rec = self.unpackaerecord(rec)
|
||||
rec[AEType(kae.pClass)] = AEType(struct.pack('>I', desc.descriptorType()))
|
||||
return rec
|
||||
# return as-is
|
||||
return desc
|
||||
|
||||
##
|
||||
|
||||
def _packbytes(self, desctype, data):
|
||||
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
|
||||
fourcharcode(desctype), data, len(data))
|
||||
|
||||
def packdesc(self, val):
|
||||
return val
|
||||
|
||||
def packnone(self, val):
|
||||
return NSAppleEventDescriptor.nullDescriptor()
|
||||
|
||||
def packbool(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
|
||||
|
||||
def packint(self, val):
|
||||
if (-2**31) <= val < (2**31):
|
||||
return NSAppleEventDescriptor.descriptorWithInt32_(val)
|
||||
else:
|
||||
return self.pack(float(val))
|
||||
|
||||
def packfloat(self, val):
|
||||
return self._packbytes(kae.typeFloat, struct.pack('d', val))
|
||||
|
||||
def packbytes(self, val):
|
||||
return self._packbytes(kae.typeData, val)
|
||||
|
||||
def packstr(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithString_(val)
|
||||
|
||||
def packdatetime(self, val):
|
||||
delta = val - self.kMacEpoch
|
||||
sec = delta.days * 3600 * 24 + delta.seconds
|
||||
return self._packbytes(kae.typeLongDateTime, struct.pack('q', sec))
|
||||
|
||||
def packlist(self, val):
|
||||
lst = NSAppleEventDescriptor.listDescriptor()
|
||||
for item in val:
|
||||
lst.insertDescriptor_atIndex_(self.pack(item), 0)
|
||||
return lst
|
||||
|
||||
def packdict(self, val):
|
||||
record = NSAppleEventDescriptor.recordDescriptor()
|
||||
usrf = desctype = None
|
||||
for key, value in val.items():
|
||||
if isinstance(key, AEType):
|
||||
if key.code == kae.pClass and isinstance(value, AEType): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
|
||||
desctype = value
|
||||
else:
|
||||
record.setDescriptor_forKeyword_(self.pack(value), fourcharcode(key.code))
|
||||
else:
|
||||
if not usrf:
|
||||
usrf = NSAppleEventDescriptor.listDescriptor()
|
||||
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
|
||||
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
|
||||
if usrf:
|
||||
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
|
||||
if desctype:
|
||||
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
|
||||
if newrecord:
|
||||
record = newrecord
|
||||
else: # coercion failed for some reason, so pack as normal key-value pair
|
||||
record.setDescriptor_forKeyword_(self.pack(desctype), fourcharcode(key.code))
|
||||
return record
|
||||
|
||||
def packtype(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
|
||||
|
||||
def packenum(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
|
||||
|
||||
#######
|
||||
|
||||
def unpacknull(self, desc):
|
||||
return None
|
||||
|
||||
def unpackboolean(self, desc):
|
||||
return desc.booleanValue()
|
||||
|
||||
def unpacksint32(self, desc):
|
||||
return desc.int32Value()
|
||||
|
||||
def unpackfloat64(self, desc):
|
||||
return struct.unpack('d', bytes(desc.data()))[0]
|
||||
|
||||
def unpackunicodetext(self, desc):
|
||||
return desc.stringValue()
|
||||
|
||||
def unpacklongdatetime(self, desc):
|
||||
return self.kMacEpoch + datetime.timedelta(seconds=struct.unpack('q', bytes(desc.data()))[0])
|
||||
|
||||
def unpackaelist(self, desc):
|
||||
return [self.unpack(desc.descriptorAtIndex_(i + 1)) for i in range(desc.numberOfItems())]
|
||||
|
||||
def unpackaerecord(self, desc):
|
||||
dct = {}
|
||||
for i in range(desc.numberOfItems()):
|
||||
key = desc.keywordForDescriptorAtIndex_(i + 1)
|
||||
value = desc.descriptorForKeyword_(key)
|
||||
if key == self.kUSRF:
|
||||
lst = self.unpackaelist(value)
|
||||
for i in range(0, len(lst), 2):
|
||||
dct[lst[i]] = lst[i+1]
|
||||
else:
|
||||
dct[AEType(struct.pack('>I', key))] = self.unpack(value)
|
||||
return dct
|
||||
|
||||
def unpacktype(self, desc):
|
||||
return AEType(struct.pack('>I', desc.typeCodeValue()))
|
||||
|
||||
def unpackenumeration(self, desc):
|
||||
return AEEnum(struct.pack('>I', desc.enumCodeValue()))
|
||||
|
||||
def unpackfile(self, desc):
|
||||
url = bytes(desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()).decode('utf8')
|
||||
return NSURL.URLWithString_(url).path()
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class AETypeBase:
|
||||
""" Base class for AEType and AEEnum.
|
||||
|
||||
Notes:
|
||||
|
||||
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
|
||||
"""
|
||||
|
||||
def __init__(self, code):
|
||||
"""
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
"""
|
||||
if not isinstance(code, bytes):
|
||||
raise TypeError('invalid code (not a bytes object): {!r}'.format(code))
|
||||
elif len(code) != 4:
|
||||
raise ValueError('invalid code (not four bytes long): {!r}'.format(code))
|
||||
self._code = code
|
||||
|
||||
code = property(lambda self:self._code, doc="bytes -- four-char code, e.g. b'utxt'")
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self._code)
|
||||
|
||||
def __eq__(self, val):
|
||||
return val.__class__ == self.__class__ and val.code == self._code
|
||||
|
||||
def __ne__(self, val):
|
||||
return not self == val
|
||||
|
||||
def __repr__(self):
|
||||
return "{}({!r})".format(self.__class__.__name__, self._code)
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class AEType(AETypeBase):
|
||||
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
|
||||
|
||||
|
||||
class AEEnum(AETypeBase):
|
||||
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""
|
||||
|
||||
1720
osxphotos/_applescript/kae.py
Normal file
1720
osxphotos/_applescript/kae.py
Normal file
File diff suppressed because it is too large
Load Diff
39
osxphotos/_constants.py
Normal file
39
osxphotos/_constants.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Constants used by osxphotos
|
||||
"""
|
||||
|
||||
import os.path
|
||||
|
||||
# which Photos library database versions have been tested
|
||||
# Photos 2.0 (10.12.6) == 2622
|
||||
# Photos 3.0 (10.13.6) == 3301
|
||||
# Photos 4.0 (10.14.5) == 4016
|
||||
# Photos 4.0 (10.14.6) == 4025
|
||||
# Photos 5.0 (10.15.0) == 6000
|
||||
# TODO: Should this also use compatibleBackToVersion from LiGlobals?
|
||||
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
|
||||
|
||||
# only version 3 - 4 have RKVersion.selfPortrait
|
||||
_PHOTOS_3_VERSION = "3301"
|
||||
|
||||
# versions later than this have a different database structure
|
||||
_PHOTOS_5_VERSION = "6000"
|
||||
|
||||
# which major version operating systems have been tested
|
||||
_TESTED_OS_VERSIONS = ["12", "13", "14", "15"]
|
||||
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
_UNKNOWN_PERSON = "_UNKNOWN_"
|
||||
|
||||
_EXIF_TOOL_URL = "https://exiftool.org/"
|
||||
|
||||
# Where are shared iCloud photos located?
|
||||
_PHOTOS_5_SHARED_PHOTO_PATH = "resources/cloudsharing/data"
|
||||
|
||||
# What type of file? Based on ZGENERICASSET.ZKIND in Photos 5 database
|
||||
_PHOTO_TYPE = 0
|
||||
_MOVIE_TYPE = 1
|
||||
|
||||
# Name of XMP template file
|
||||
_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
||||
_XMP_TEMPLATE_NAME = "xmp_sidecar.mako"
|
||||
3
osxphotos/_version.py
Normal file
3
osxphotos/_version.py
Normal file
@@ -0,0 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.24.2"
|
||||
@@ -1,294 +0,0 @@
|
||||
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
|
||||
|
||||
import datetime, struct, sys
|
||||
|
||||
from Foundation import NSAppleEventDescriptor, NSURL
|
||||
|
||||
from . import kae
|
||||
|
||||
|
||||
__all__ = ["Codecs", "AEType", "AEEnum"]
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
def fourcharcode(code):
|
||||
""" Convert four-char code for use in NSAppleEventDescriptor methods.
|
||||
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
Result : int -- OSType, e.g. 1970567284
|
||||
"""
|
||||
return struct.unpack(">I", code)[0]
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class Codecs:
|
||||
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
|
||||
|
||||
kMacEpoch = datetime.datetime(1904, 1, 1)
|
||||
kUSRF = fourcharcode(kae.keyASUserRecordFields)
|
||||
|
||||
def __init__(self):
|
||||
# Clients may add/remove/replace encoder and decoder items:
|
||||
self.encoders = {
|
||||
NSAppleEventDescriptor.class__(): self.packdesc,
|
||||
type(None): self.packnone,
|
||||
bool: self.packbool,
|
||||
int: self.packint,
|
||||
float: self.packfloat,
|
||||
bytes: self.packbytes,
|
||||
str: self.packstr,
|
||||
list: self.packlist,
|
||||
tuple: self.packlist,
|
||||
dict: self.packdict,
|
||||
datetime.datetime: self.packdatetime,
|
||||
AEType: self.packtype,
|
||||
AEEnum: self.packenum,
|
||||
}
|
||||
if sys.version_info.major < 3: # 2.7 compatibility
|
||||
self.encoders[unicode] = self.packstr
|
||||
|
||||
self.decoders = {
|
||||
fourcharcode(k): v
|
||||
for k, v in {
|
||||
kae.typeNull: self.unpacknull,
|
||||
kae.typeBoolean: self.unpackboolean,
|
||||
kae.typeFalse: self.unpackboolean,
|
||||
kae.typeTrue: self.unpackboolean,
|
||||
kae.typeSInt32: self.unpacksint32,
|
||||
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
|
||||
kae.typeUTF8Text: self.unpackunicodetext,
|
||||
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
|
||||
kae.typeUnicodeText: self.unpackunicodetext,
|
||||
kae.typeLongDateTime: self.unpacklongdatetime,
|
||||
kae.typeAEList: self.unpackaelist,
|
||||
kae.typeAERecord: self.unpackaerecord,
|
||||
kae.typeAlias: self.unpackfile,
|
||||
kae.typeFSS: self.unpackfile,
|
||||
kae.typeFSRef: self.unpackfile,
|
||||
kae.typeFileURL: self.unpackfile,
|
||||
kae.typeType: self.unpacktype,
|
||||
kae.typeEnumeration: self.unpackenumeration,
|
||||
}.items()
|
||||
}
|
||||
|
||||
def pack(self, data):
|
||||
"""Pack Python data.
|
||||
data : anything -- a Python value
|
||||
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
|
||||
"""
|
||||
try:
|
||||
return self.encoders[data.__class__](data) # quick lookup by type/class
|
||||
except (KeyError, AttributeError) as e:
|
||||
for (
|
||||
type,
|
||||
encoder,
|
||||
) in (
|
||||
self.encoders.items()
|
||||
): # slower but more thorough lookup that can handle subtypes/subclasses
|
||||
if isinstance(data, type):
|
||||
return encoder(data)
|
||||
raise TypeError(
|
||||
"Can't pack data into an AEDesc (unsupported type): {!r}".format(data)
|
||||
)
|
||||
|
||||
def unpack(self, desc):
|
||||
"""Unpack an Apple event descriptor.
|
||||
desc : NSAppleEventDescriptor
|
||||
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
|
||||
"""
|
||||
decoder = self.decoders.get(desc.descriptorType())
|
||||
# unpack known type
|
||||
if decoder:
|
||||
return decoder(desc)
|
||||
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
|
||||
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
|
||||
if rec:
|
||||
rec = self.unpackaerecord(rec)
|
||||
rec[AEType(kae.pClass)] = AEType(struct.pack(">I", desc.descriptorType()))
|
||||
return rec
|
||||
# return as-is
|
||||
return desc
|
||||
|
||||
##
|
||||
|
||||
def _packbytes(self, desctype, data):
|
||||
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
|
||||
fourcharcode(desctype), data, len(data)
|
||||
)
|
||||
|
||||
def packdesc(self, val):
|
||||
return val
|
||||
|
||||
def packnone(self, val):
|
||||
return NSAppleEventDescriptor.nullDescriptor()
|
||||
|
||||
def packbool(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
|
||||
|
||||
def packint(self, val):
|
||||
if (-2 ** 31) <= val < (2 ** 31):
|
||||
return NSAppleEventDescriptor.descriptorWithInt32_(val)
|
||||
else:
|
||||
return self.pack(float(val))
|
||||
|
||||
def packfloat(self, val):
|
||||
return self._packbytes(kae.typeFloat, struct.pack("d", val))
|
||||
|
||||
def packbytes(self, val):
|
||||
return self._packbytes(kae.typeData, val)
|
||||
|
||||
def packstr(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithString_(val)
|
||||
|
||||
def packdatetime(self, val):
|
||||
delta = val - self.kMacEpoch
|
||||
sec = delta.days * 3600 * 24 + delta.seconds
|
||||
return self._packbytes(kae.typeLongDateTime, struct.pack("q", sec))
|
||||
|
||||
def packlist(self, val):
|
||||
lst = NSAppleEventDescriptor.listDescriptor()
|
||||
for item in val:
|
||||
lst.insertDescriptor_atIndex_(self.pack(item), 0)
|
||||
return lst
|
||||
|
||||
def packdict(self, val):
|
||||
record = NSAppleEventDescriptor.recordDescriptor()
|
||||
usrf = desctype = None
|
||||
for key, value in val.items():
|
||||
if isinstance(key, AEType):
|
||||
if key.code == kae.pClass and isinstance(
|
||||
value, AEType
|
||||
): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
|
||||
desctype = value
|
||||
else:
|
||||
record.setDescriptor_forKeyword_(
|
||||
self.pack(value), fourcharcode(key.code)
|
||||
)
|
||||
else:
|
||||
if not usrf:
|
||||
usrf = NSAppleEventDescriptor.listDescriptor()
|
||||
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
|
||||
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
|
||||
if usrf:
|
||||
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
|
||||
if desctype:
|
||||
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
|
||||
if newrecord:
|
||||
record = newrecord
|
||||
else: # coercion failed for some reason, so pack as normal key-value pair
|
||||
record.setDescriptor_forKeyword_(
|
||||
self.pack(desctype), fourcharcode(key.code)
|
||||
)
|
||||
return record
|
||||
|
||||
def packtype(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
|
||||
|
||||
def packenum(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
|
||||
|
||||
#######
|
||||
|
||||
def unpacknull(self, desc):
|
||||
return None
|
||||
|
||||
def unpackboolean(self, desc):
|
||||
return desc.booleanValue()
|
||||
|
||||
def unpacksint32(self, desc):
|
||||
return desc.int32Value()
|
||||
|
||||
def unpackfloat64(self, desc):
|
||||
return struct.unpack("d", bytes(desc.data()))[0]
|
||||
|
||||
def unpackunicodetext(self, desc):
|
||||
return desc.stringValue()
|
||||
|
||||
def unpacklongdatetime(self, desc):
|
||||
return self.kMacEpoch + datetime.timedelta(
|
||||
seconds=struct.unpack("q", bytes(desc.data()))[0]
|
||||
)
|
||||
|
||||
def unpackaelist(self, desc):
|
||||
return [
|
||||
self.unpack(desc.descriptorAtIndex_(i + 1))
|
||||
for i in range(desc.numberOfItems())
|
||||
]
|
||||
|
||||
def unpackaerecord(self, desc):
|
||||
dct = {}
|
||||
for i in range(desc.numberOfItems()):
|
||||
key = desc.keywordForDescriptorAtIndex_(i + 1)
|
||||
value = desc.descriptorForKeyword_(key)
|
||||
if key == self.kUSRF:
|
||||
lst = self.unpackaelist(value)
|
||||
for i in range(0, len(lst), 2):
|
||||
dct[lst[i]] = lst[i + 1]
|
||||
else:
|
||||
dct[AEType(struct.pack(">I", key))] = self.unpack(value)
|
||||
return dct
|
||||
|
||||
def unpacktype(self, desc):
|
||||
return AEType(struct.pack(">I", desc.typeCodeValue()))
|
||||
|
||||
def unpackenumeration(self, desc):
|
||||
return AEEnum(struct.pack(">I", desc.enumCodeValue()))
|
||||
|
||||
def unpackfile(self, desc):
|
||||
url = bytes(
|
||||
desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()
|
||||
).decode("utf8")
|
||||
return NSURL.URLWithString_(url).path()
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class AETypeBase:
|
||||
""" Base class for AEType and AEEnum.
|
||||
|
||||
Notes:
|
||||
|
||||
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
|
||||
"""
|
||||
|
||||
def __init__(self, code):
|
||||
"""
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
"""
|
||||
if not isinstance(code, bytes):
|
||||
raise TypeError("invalid code (not a bytes object): {!r}".format(code))
|
||||
elif len(code) != 4:
|
||||
raise ValueError("invalid code (not four bytes long): {!r}".format(code))
|
||||
self._code = code
|
||||
|
||||
code = property(
|
||||
lambda self: self._code, doc="bytes -- four-char code, e.g. b'utxt'"
|
||||
)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self._code)
|
||||
|
||||
def __eq__(self, val):
|
||||
return val.__class__ == self.__class__ and val.code == self._code
|
||||
|
||||
def __ne__(self, val):
|
||||
return not self == val
|
||||
|
||||
def __repr__(self):
|
||||
return "{}({!r})".format(self.__class__.__name__, self._code)
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class AEType(AETypeBase):
|
||||
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
|
||||
|
||||
|
||||
class AEEnum(AETypeBase):
|
||||
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""
|
||||
@@ -1,200 +0,0 @@
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
class CLI_Obj:
|
||||
def __init__(self, db=None, json=False):
|
||||
self.photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
self.json = json
|
||||
|
||||
|
||||
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
|
||||
@click.group(context_settings=CTX_SETTINGS)
|
||||
@click.option(
|
||||
"--db",
|
||||
required=False,
|
||||
metavar="<Photos database path>",
|
||||
default=None,
|
||||
help="Specify database file",
|
||||
)
|
||||
@click.option(
|
||||
"--json",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print output in JSON format",
|
||||
)
|
||||
@click.pass_context
|
||||
def cli(ctx, db, json):
|
||||
ctx.obj = CLI_Obj(db=db, json=json)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_obj
|
||||
def keywords(cli_obj):
|
||||
""" print out keywords found in the Photos library"""
|
||||
keywords = {"keywords": cli_obj.photosdb.keywords_as_dict()}
|
||||
if cli_obj.json:
|
||||
print(json.dumps(keywords))
|
||||
else:
|
||||
print(yaml.dump(keywords, sort_keys=False))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_obj
|
||||
def albums(cli_obj):
|
||||
""" print out albums found in the Photos library """
|
||||
albums = {"albums": cli_obj.photosdb.albums_as_dict()}
|
||||
if cli_obj.json:
|
||||
print(json.dumps(albums))
|
||||
else:
|
||||
print(yaml.dump(albums, sort_keys=False))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_obj
|
||||
def persons(cli_obj):
|
||||
""" print out persons (faces) found in the Photos library """
|
||||
persons = {"persons": cli_obj.photosdb.persons_as_dict()}
|
||||
if cli_obj.json:
|
||||
print(json.dumps(persons))
|
||||
else:
|
||||
print(yaml.dump(persons, sort_keys=False))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_obj
|
||||
def info(cli_obj):
|
||||
""" print out descriptive info of the Photos library database """
|
||||
pdb = cli_obj.photosdb
|
||||
info = {}
|
||||
info["database_path"] = pdb.get_db_path()
|
||||
info["database_version"] = pdb.get_db_version()
|
||||
|
||||
photos = pdb.photos()
|
||||
info["photo_count"] = len(photos)
|
||||
|
||||
keywords = pdb.keywords_as_dict()
|
||||
info["keywords_count"] = len(keywords)
|
||||
info["keywords"] = keywords
|
||||
|
||||
albums = pdb.albums_as_dict()
|
||||
info["albums_count"] = len(albums)
|
||||
info["albums"] = albums
|
||||
|
||||
persons = pdb.persons_as_dict()
|
||||
info["persons_count"] = len(persons)
|
||||
info["persons"] = persons
|
||||
|
||||
if cli_obj.json:
|
||||
print(json.dumps(info))
|
||||
else:
|
||||
print(yaml.dump(info, sort_keys=False))
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.pass_obj
|
||||
def dump(cli_obj):
|
||||
""" print list of all photos & associated info from the Photos library """
|
||||
pdb = cli_obj.photosdb
|
||||
photos = pdb.photos()
|
||||
print_photo_info(photos, cli_obj.json)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--keyword", default=None, multiple=True, help="search for keyword(s)")
|
||||
@click.option("--person", default=None, multiple=True, help="search for person(s)")
|
||||
@click.option("--album", default=None, multiple=True, help="search for album(s)")
|
||||
@click.option("--uuid", default=None, multiple=True, help="search for UUID(s)")
|
||||
@click.option(
|
||||
"--json",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print output in JSON format",
|
||||
)
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def query(ctx, cli_obj, keyword, person, album, uuid, json):
|
||||
""" query the Photos database using 1 or more search options """
|
||||
|
||||
# if no query terms, show help and return
|
||||
if not keyword and not person and not album and not uuid:
|
||||
print(cli.commands["query"].get_help(ctx))
|
||||
return
|
||||
else:
|
||||
photos = cli_obj.photosdb.photos(
|
||||
keywords=keyword, persons=person, albums=album, uuid=uuid
|
||||
)
|
||||
print_photo_info(photos, cli_obj.json or json)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("topic", default=None, required=False, nargs=1)
|
||||
@click.pass_context
|
||||
def help(ctx, topic, **kw):
|
||||
""" print help; for help on commands: help <command> """
|
||||
if topic is None:
|
||||
print(ctx.parent.get_help())
|
||||
else:
|
||||
print(cli.commands[topic].get_help(ctx))
|
||||
|
||||
|
||||
def print_photo_info(photos, json=False):
|
||||
if json:
|
||||
dump = []
|
||||
for p in photos:
|
||||
dump.append(p.to_json())
|
||||
print(f"[{', '.join(dump)}]")
|
||||
else:
|
||||
# dump as CSV
|
||||
csv_writer = csv.writer(
|
||||
sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
|
||||
)
|
||||
dump = []
|
||||
# add headers
|
||||
dump.append(
|
||||
[
|
||||
"uuid",
|
||||
"filename",
|
||||
"date",
|
||||
"description",
|
||||
"name",
|
||||
"keywords",
|
||||
"albums",
|
||||
"persons",
|
||||
"path",
|
||||
"ismissing",
|
||||
"hasadjustments",
|
||||
]
|
||||
)
|
||||
for p in photos:
|
||||
dump.append(
|
||||
[
|
||||
p.uuid(),
|
||||
p.filename(),
|
||||
str(p.date()),
|
||||
p.description(),
|
||||
p.name(),
|
||||
", ".join(p.keywords()),
|
||||
", ".join(p.albums()),
|
||||
", ".join(p.persons()),
|
||||
p.path(),
|
||||
p.ismissing(),
|
||||
p.hasadjustments(),
|
||||
]
|
||||
)
|
||||
for row in dump:
|
||||
csv_writer.writerow(row)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
251
osxphotos/exiftool.py
Normal file
251
osxphotos/exiftool.py
Normal file
@@ -0,0 +1,251 @@
|
||||
""" Yet another simple exiftool wrapper
|
||||
I rolled my own for following reasons:
|
||||
1. I wanted something under MIT license (best alternative was licensed under GPL/BSD)
|
||||
2. I wanted singleton behavior so only a single exiftool process was ever running
|
||||
If these aren't important to you, I highly recommend you use Sven Marnach's excellent
|
||||
pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache # pylint: disable=syntax-error
|
||||
|
||||
from .utils import _debug
|
||||
|
||||
# exiftool -stay_open commands outputs this EOF marker after command is run
|
||||
EXIFTOOL_STAYOPEN_EOF = "{ready}"
|
||||
EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_exiftool_path():
|
||||
""" return path of exiftool, cache result """
|
||||
result = subprocess.run(["which", "exiftool"], stdout=subprocess.PIPE)
|
||||
exiftool_path = result.stdout.decode("utf-8")
|
||||
if _debug():
|
||||
logging.debug("exiftool path = %s" % (exiftool_path))
|
||||
if exiftool_path:
|
||||
return exiftool_path.rstrip()
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
"Could not find exiftool. Please download and install from "
|
||||
"https://exiftool.org/"
|
||||
)
|
||||
|
||||
|
||||
class _ExifToolProc:
|
||||
""" Runs exiftool in a subprocess via Popen
|
||||
Creates a singleton object """
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
""" create new object or return instance of already created singleton """
|
||||
if not hasattr(cls, "instance") or not cls.instance:
|
||||
cls.instance = super().__new__(cls)
|
||||
|
||||
return cls.instance
|
||||
|
||||
def __init__(self, exiftool=None):
|
||||
""" construct _ExifToolProc singleton object or return instance of already created object
|
||||
exiftool: optional path to exiftool binary (if not provided, will search path to find it) """
|
||||
|
||||
if hasattr(self, "_process_running") and self._process_running:
|
||||
# already running
|
||||
if exiftool is not None:
|
||||
logging.warning(
|
||||
f"exiftool subprocess already running, "
|
||||
f"ignoring exiftool={exiftool}"
|
||||
)
|
||||
return
|
||||
|
||||
if exiftool:
|
||||
self._exiftool = exiftool
|
||||
else:
|
||||
self._exiftool = get_exiftool_path()
|
||||
|
||||
self._process_running = False
|
||||
self._start_proc()
|
||||
|
||||
@property
|
||||
def process(self):
|
||||
""" return the exiftool subprocess """
|
||||
if self._process_running:
|
||||
return self._process
|
||||
else:
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
""" return process id (PID) of the exiftool process """
|
||||
return self._process.pid
|
||||
|
||||
@property
|
||||
def exiftool(self):
|
||||
""" return path to exiftool process """
|
||||
return self._exiftool
|
||||
|
||||
def _start_proc(self):
|
||||
""" start exiftool in batch mode """
|
||||
|
||||
if self._process_running:
|
||||
logging.warning("exiftool already running: {self._process}")
|
||||
return
|
||||
|
||||
# open exiftool process
|
||||
self._process = subprocess.Popen(
|
||||
[
|
||||
self._exiftool,
|
||||
"-stay_open", # keep process open in batch mode
|
||||
"True", # -stay_open=True, keep process open in batch mode
|
||||
"-@", # read command-line arguments from file
|
||||
"-", # read from stdin
|
||||
"-common_args", # specifies args common to all commands subsequently run
|
||||
"-n", # no print conversion (e.g. print tag values in machine readable format)
|
||||
"-G", # print group name for each tag
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self._process_running = True
|
||||
|
||||
def _stop_proc(self):
|
||||
""" stop the exiftool process if it's running, otherwise, do nothing """
|
||||
if not self._process_running:
|
||||
logging.warning("exiftool process is not running")
|
||||
return
|
||||
|
||||
self._process.stdin.write(b"-stay_open\n")
|
||||
self._process.stdin.write(b"False\n")
|
||||
self._process.stdin.flush()
|
||||
try:
|
||||
self._process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.warning(
|
||||
f"exiftool pid {self._process.pid} did not exit, killing it"
|
||||
)
|
||||
self._process.kill()
|
||||
self._process.communicate()
|
||||
|
||||
del self._process
|
||||
self._process_running = False
|
||||
|
||||
def __del__(self):
|
||||
self._stop_proc()
|
||||
|
||||
|
||||
class ExifTool:
|
||||
""" Basic exiftool interface for reading and writing EXIF tags """
|
||||
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True):
|
||||
""" Return ExifTool object
|
||||
file: path to image file
|
||||
exiftool: path to exiftool, if not specified will look in path
|
||||
overwrite: if True, will overwrite image file without creating backup, default=False """
|
||||
self.file = filepath
|
||||
self.overwrite = overwrite
|
||||
self.data = {}
|
||||
self._exiftoolproc = _ExifToolProc(exiftool=exiftool)
|
||||
self._process = self._exiftoolproc.process
|
||||
self._read_exif()
|
||||
|
||||
def setvalue(self, tag, value):
|
||||
""" Set tag to value(s)
|
||||
if value is None, will delete tag """
|
||||
|
||||
if value is None:
|
||||
value = ""
|
||||
command = []
|
||||
command.append(f"-{tag}={value}")
|
||||
if self.overwrite:
|
||||
command.append("-overwrite_original")
|
||||
self.run_commands(*command)
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
""" Add one or more value(s) to tag
|
||||
If more than one value is passed, each value will be added to the tag
|
||||
Notes: exiftool may add duplicate values for some tags so the caller must ensure
|
||||
the values being added are not already in the EXIF data
|
||||
For some tags, such as IPTC:Keywords, this will add a new value to the list of keywords,
|
||||
but for others, such as EXIF:ISO, this will literally add a value to the existing value.
|
||||
It's up to the caller to know what exiftool will do for each tag
|
||||
If setvalue called before addvalues, exiftool does not appear to add duplicates,
|
||||
but if addvalues called without first calling setvalue, exiftool will add duplicate values
|
||||
"""
|
||||
if not values:
|
||||
raise ValueError("Must pass at least one value")
|
||||
|
||||
command = []
|
||||
for value in values:
|
||||
if value is None:
|
||||
raise ValueError("Can't add None value to tag")
|
||||
command.append(f"-{tag}+={value}")
|
||||
|
||||
if self.overwrite:
|
||||
command.append("-overwrite_original")
|
||||
|
||||
if command:
|
||||
self.run_commands(*command)
|
||||
|
||||
def run_commands(self, *commands, no_file=False):
|
||||
""" run commands in the exiftool process and return result
|
||||
no_file: (bool) do not pass the filename to exiftool (default=False)
|
||||
by default, all commands will be run against self.file
|
||||
use no_file=True to run a command without passing the filename """
|
||||
if not hasattr(self, "_process") or not self._process:
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
if not commands:
|
||||
raise TypeError("must provide one or more command to run")
|
||||
|
||||
filename = os.fsencode(self.file) if not no_file else b""
|
||||
command_str = (
|
||||
b"\n".join([c.encode("utf-8") for c in commands])
|
||||
+ b"\n"
|
||||
+ filename
|
||||
+ b"\n"
|
||||
+ b"-execute\n"
|
||||
)
|
||||
|
||||
if _debug():
|
||||
logging.debug(command_str)
|
||||
|
||||
# send the command
|
||||
self._process.stdin.write(command_str)
|
||||
self._process.stdin.flush()
|
||||
|
||||
# read the output
|
||||
output = b""
|
||||
while EXIFTOOL_STAYOPEN_EOF not in str(output):
|
||||
output += self._process.stdout.readline().strip()
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN]
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
""" return process id (PID) of the exiftool process """
|
||||
return self._process.pid
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
""" returns exiftool version """
|
||||
ver = self.run_commands("-ver", no_file=True)
|
||||
return ver.decode("utf-8")
|
||||
|
||||
def json(self):
|
||||
""" return JSON dictionary from exiftool as dict """
|
||||
json_str = self.run_commands("-json")
|
||||
if json_str:
|
||||
return json.loads(json_str)
|
||||
else:
|
||||
return None
|
||||
|
||||
def _read_exif(self):
|
||||
""" read exif data from file """
|
||||
json = self.json()
|
||||
self.data = {k: v for k, v in json[0].items()}
|
||||
|
||||
def __str__(self):
|
||||
str_ = f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
|
||||
return str_
|
||||
|
||||
1703
osxphotos/kae.py
1703
osxphotos/kae.py
File diff suppressed because it is too large
Load Diff
993
osxphotos/photoinfo.py
Normal file
993
osxphotos/photoinfo.py
Normal file
@@ -0,0 +1,993 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
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 (
|
||||
_MOVIE_TYPE,
|
||||
_PHOTO_TYPE,
|
||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
_PHOTOS_5_VERSION,
|
||||
_TEMPLATE_DIR,
|
||||
_XMP_TEMPLATE_NAME,
|
||||
)
|
||||
from .exiftool import ExifTool
|
||||
from .placeinfo import PlaceInfo4, PlaceInfo5
|
||||
from .utils import (
|
||||
_copy_file,
|
||||
_export_photo_uuid_applescript,
|
||||
_get_resource_loc,
|
||||
dd_to_dms_str,
|
||||
)
|
||||
|
||||
|
||||
class PhotoInfo:
|
||||
"""
|
||||
Info about a specific photo, contains all the details about the photo
|
||||
including keywords, persons, albums, uuid, path, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self._uuid = uuid
|
||||
self._info = info
|
||||
self._db = db
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
""" filename of the picture """
|
||||
return self._info["filename"]
|
||||
|
||||
@property
|
||||
def original_filename(self):
|
||||
""" original filename of the picture
|
||||
Photos 5 mangles filenames upon import """
|
||||
return self._info["originalFilename"]
|
||||
|
||||
@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
|
||||
|
||||
@property
|
||||
def date_modified(self):
|
||||
""" image modification date as timezone aware datetime object
|
||||
or None if no modification date set """
|
||||
imagedate = self._info["lastmodifieddate"]
|
||||
if imagedate:
|
||||
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
imagedate_utc = imagedate.astimezone(tz=tz)
|
||||
return imagedate_utc
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def tzoffset(self):
|
||||
""" timezone offset from UTC in seconds """
|
||||
return self._info["imageTimeZoneOffsetSeconds"]
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
""" absolute path on disk of the original picture """
|
||||
|
||||
photopath = None
|
||||
if self._info["isMissing"] == 1:
|
||||
return photopath # path would be meaningless until downloaded
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
vol = self._info["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["imagePath"]
|
||||
)
|
||||
return photopath
|
||||
# TODO: Is there a way to use applescript or PhotoKit to force the download in this
|
||||
|
||||
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._info["directory"].startswith("/"):
|
||||
photopath = os.path.join(self._info["directory"], self._info["filename"])
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["directory"], self._info["filename"]
|
||||
)
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def path_edited(self):
|
||||
""" absolute path on disk of the edited picture """
|
||||
""" None if photo has not been edited """
|
||||
|
||||
# TODO: break this code into a _path_edited_4 and _path_edited_5
|
||||
# version to simplify the big if/then; same for path_live_photo
|
||||
|
||||
photopath = None
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self._info["hasAdjustments"]:
|
||||
edit_id = self._info["edit_resource_id"]
|
||||
if edit_id is not None:
|
||||
library = self._db._library_path
|
||||
folder_id, file_id = _get_resource_loc(edit_id)
|
||||
# todo: is this always true or do we need to search file file_id under folder_id
|
||||
# figure out what kind it is and build filename
|
||||
filename = None
|
||||
if self._info["type"] == _PHOTO_TYPE:
|
||||
# it's a photo
|
||||
filename = f"fullsizeoutput_{file_id}.jpeg"
|
||||
elif self._info["type"] == _MOVIE_TYPE:
|
||||
# it's a movie
|
||||
filename = f"fullsizeoutput_{file_id}.mov"
|
||||
else:
|
||||
# don't know what it is!
|
||||
logging.debug(f"WARNING: unknown type {self._info['type']}")
|
||||
return None
|
||||
|
||||
# photopath appears to usually be in "00" subfolder but
|
||||
# could be elsewhere--I haven't figured out this logic yet
|
||||
# first see if it's in 00
|
||||
photopath = os.path.join(
|
||||
library,
|
||||
"resources",
|
||||
"media",
|
||||
"version",
|
||||
folder_id,
|
||||
"00",
|
||||
filename,
|
||||
)
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
rootdir = os.path.join(
|
||||
library, "resources", "media", "version", folder_id
|
||||
)
|
||||
|
||||
for dirname, _, filelist in os.walk(rootdir):
|
||||
if filename in filelist:
|
||||
photopath = os.path.join(dirname, filename)
|
||||
break
|
||||
|
||||
# check again to see if we found a valid file
|
||||
if not os.path.isfile(photopath):
|
||||
logging.debug(
|
||||
f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
logging.debug(
|
||||
f"{self.uuid} hasAdjustments but edit_resource_id is None"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
photopath = None
|
||||
|
||||
# if self._info["isMissing"] == 1:
|
||||
# photopath = None # path would be meaningless until downloaded
|
||||
else:
|
||||
# in Photos 5.0 / Catalina / MacOS 10.15:
|
||||
# edited photos appear to always be converted to .jpeg and stored in
|
||||
# library_name/resources/renders/X/UUID_1_201_a.jpeg
|
||||
# where X = first letter of UUID
|
||||
# and UUID = UUID of image
|
||||
# this seems to be true even for photos not copied to Photos library and
|
||||
# where original format was not jpg/jpeg
|
||||
# if more than one edit, previous edit is stored as UUID_p.jpeg
|
||||
|
||||
if self._info["hasAdjustments"]:
|
||||
library = self._db._library_path
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
filename = None
|
||||
if self._info["type"] == _PHOTO_TYPE:
|
||||
# it's a photo
|
||||
filename = f"{self._uuid}_1_201_a.jpeg"
|
||||
elif self._info["type"] == _MOVIE_TYPE:
|
||||
# it's a movie
|
||||
filename = f"{self._uuid}_2_0_a.mov"
|
||||
else:
|
||||
# don't know what it is!
|
||||
logging.debug(f"WARNING: unknown type {self._info['type']}")
|
||||
return None
|
||||
|
||||
photopath = os.path.join(
|
||||
library, "resources", "renders", directory, filename
|
||||
)
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
logging.debug(
|
||||
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
photopath = None
|
||||
|
||||
# TODO: might be possible for original/master to be missing but edit to still be there
|
||||
# if self._info["isMissing"] == 1:
|
||||
# photopath = None # path would be meaningless until downloaded
|
||||
|
||||
logging.debug(photopath)
|
||||
|
||||
return photopath
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
""" long / extended description of picture """
|
||||
return self._info["extendedDescription"]
|
||||
|
||||
@property
|
||||
def persons(self):
|
||||
""" list of persons in picture """
|
||||
return self._info["persons"]
|
||||
|
||||
@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
|
||||
|
||||
@property
|
||||
def keywords(self):
|
||||
""" list of keywords for picture """
|
||||
return self._info["keywords"]
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" name / title of picture """
|
||||
return self._info["name"]
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" UUID of picture """
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def ismissing(self):
|
||||
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
|
||||
NOTE: the photos.db database uses an asynchrounous write-ahead log so changes in Photos
|
||||
do not immediately get written to disk. In particular, I've noticed that downloading
|
||||
an image from the cloud does not force the database to be updated until something else
|
||||
e.g. an edit, keyword, etc. occurs forcing a database synch
|
||||
The exact process / timing is a mystery to be but be aware that if some photos were recently
|
||||
downloaded from cloud to local storate their status in the database might still show
|
||||
isMissing = 1
|
||||
"""
|
||||
return True if self._info["isMissing"] == 1 else False
|
||||
|
||||
@property
|
||||
def hasadjustments(self):
|
||||
""" True if picture has adjustments / edits """
|
||||
return True if self._info["hasAdjustments"] == 1 else False
|
||||
|
||||
@property
|
||||
def external_edit(self):
|
||||
""" Returns True if picture was edited outside of Photos using external editor """
|
||||
return (
|
||||
True
|
||||
if self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
|
||||
else False
|
||||
)
|
||||
|
||||
@property
|
||||
def favorite(self):
|
||||
""" True if picture is marked as favorite """
|
||||
return True if self._info["favorite"] == 1 else False
|
||||
|
||||
@property
|
||||
def hidden(self):
|
||||
""" True if picture is hidden """
|
||||
return True if self._info["hidden"] == 1 else False
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
""" returns (latitude, longitude) as float in degrees or None """
|
||||
return (self._latitude, self._longitude)
|
||||
|
||||
@property
|
||||
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:
|
||||
return self._info["shared"]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def uti(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""
|
||||
return self._info["UTI"]
|
||||
|
||||
@property
|
||||
def ismovie(self):
|
||||
""" Returns True if file is a movie, otherwise False
|
||||
"""
|
||||
return True if self._info["type"] == _MOVIE_TYPE else False
|
||||
|
||||
@property
|
||||
def isphoto(self):
|
||||
""" Returns True if file is an image, otherwise False
|
||||
"""
|
||||
return True if self._info["type"] == _PHOTO_TYPE else False
|
||||
|
||||
@property
|
||||
def incloud(self):
|
||||
""" Returns True if photo is cloud asset and is synched to cloud
|
||||
False if photo is cloud asset and not yet synched to cloud
|
||||
None if photo is not cloud asset
|
||||
"""
|
||||
return self._info["incloud"]
|
||||
|
||||
@property
|
||||
def iscloudasset(self):
|
||||
""" Returns True if photo is a cloud asset (in an iCloud library),
|
||||
otherwise False
|
||||
"""
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
return (
|
||||
True
|
||||
if self._info["cloudLibraryState"] is not None
|
||||
and self._info["cloudLibraryState"] != 0
|
||||
else False
|
||||
)
|
||||
else:
|
||||
return True if self._info["cloudAssetGUID"] is not None else False
|
||||
|
||||
@property
|
||||
def burst(self):
|
||||
""" Returns True if photo is part of a Burst photo set, otherwise False """
|
||||
return self._info["burst"]
|
||||
|
||||
@property
|
||||
def burst_photos(self):
|
||||
""" If photo is a burst photo, returns list of PhotoInfo objects
|
||||
that are part of the same burst photo set; otherwise returns empty list.
|
||||
self is not included in the returned list """
|
||||
if self._info["burst"]:
|
||||
burst_uuid = self._info["burstUUID"]
|
||||
burst_photos = [
|
||||
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 []
|
||||
|
||||
@property
|
||||
def live_photo(self):
|
||||
""" Returns True if photo is a live photo, otherwise False """
|
||||
return self._info["live_photo"]
|
||||
|
||||
@property
|
||||
def path_live_photo(self):
|
||||
""" Returns path to the associated video file for a live photo
|
||||
If photo is not a live photo, returns None
|
||||
If photo is missing, returns None """
|
||||
|
||||
photopath = None
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
if self.live_photo and not self.ismissing:
|
||||
live_model_id = self._info["live_model_id"]
|
||||
if live_model_id == None:
|
||||
logging.debug(f"missing live_model_id: {self._uuid}")
|
||||
photopath = None
|
||||
else:
|
||||
folder_id, file_id = _get_resource_loc(live_model_id)
|
||||
library_path = self._db.library_path
|
||||
photopath = os.path.join(
|
||||
library_path,
|
||||
"resources",
|
||||
"media",
|
||||
"master",
|
||||
folder_id,
|
||||
"00",
|
||||
f"jpegvideocomplement_{file_id}.mov",
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
# In testing, I've seen occasional missing movie for live photo
|
||||
# These appear to be valid -- e.g. live component hasn't been downloaded from iCloud
|
||||
# photos 4 has "isOnDisk" column we could check
|
||||
# or could do the actual check with "isfile"
|
||||
# TODO: should this be a warning or debug?
|
||||
logging.debug(
|
||||
f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
photopath = None
|
||||
else:
|
||||
# Photos 5
|
||||
if self.live_photo and not self.ismissing:
|
||||
filename = pathlib.Path(self.path)
|
||||
photopath = filename.parent.joinpath(f"{filename.stem}_3.mov")
|
||||
photopath = str(photopath)
|
||||
if not os.path.isfile(photopath):
|
||||
# In testing, I've seen occasional missing movie for live photo
|
||||
# these appear to be valid -- e.g. video component not yet downloaded from iCloud
|
||||
# TODO: should this be a warning or debug?
|
||||
logging.debug(
|
||||
f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
)
|
||||
photopath = None
|
||||
else:
|
||||
photopath = None
|
||||
|
||||
return photopath
|
||||
|
||||
@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_5_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
|
||||
|
||||
def export(
|
||||
self,
|
||||
dest,
|
||||
*filename,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
overwrite=False,
|
||||
increment=True,
|
||||
sidecar_json=False,
|
||||
sidecar_xmp=False,
|
||||
use_photos_export=False,
|
||||
timeout=120,
|
||||
exiftool=False,
|
||||
):
|
||||
""" export photo
|
||||
dest: must be valid destination path (or exception raised)
|
||||
filename: (optional): name of exported picture; if not provided, will use current filename
|
||||
**NOTE**: if provided, user must ensure file extension (suffix) is correct.
|
||||
For example, if photo is .CR2 file, edited image may be .jpeg.
|
||||
If you provide an extension different than what the actual file is,
|
||||
export will print a warning but will happily export the photo using the
|
||||
incorrect file extension. e.g. to get the extension of the edited photo,
|
||||
reference PhotoInfo.path_edited
|
||||
edited: (boolean, default=False); if True will export the edited version of the photo
|
||||
(or raise exception if no edited version)
|
||||
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
|
||||
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
|
||||
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
|
||||
if overwrite=False and increment=False, export will fail if destination file already exists
|
||||
sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
|
||||
sidecar filename will be dest/filename.json
|
||||
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
|
||||
sidecar filename will be dest/filename.xmp
|
||||
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
|
||||
timeout: (int, default=120) timeout in seconds used with use_photos_export
|
||||
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
|
||||
returns list of full paths to the exported files """
|
||||
|
||||
# list of all files exported during this call to export
|
||||
exported_files = []
|
||||
|
||||
# check edited and raise exception trying to export edited version of
|
||||
# photo that hasn't been edited
|
||||
if edited and not self.hasadjustments:
|
||||
raise ValueError(
|
||||
"Photo does not have adjustments, cannot export edited version"
|
||||
)
|
||||
|
||||
# check arguments and get destination path and filename (if provided)
|
||||
if filename and len(filename) > 2:
|
||||
raise TypeError(
|
||||
"Too many positional arguments. Should be at most two: destination, filename."
|
||||
)
|
||||
else:
|
||||
# verify destination is a valid path
|
||||
if dest is None:
|
||||
raise ValueError("Destination must not be None")
|
||||
elif not os.path.isdir(dest):
|
||||
raise FileNotFoundError("Invalid path passed to export")
|
||||
|
||||
if filename and len(filename) == 1:
|
||||
# if filename passed, use it
|
||||
fname = filename[0]
|
||||
else:
|
||||
# no filename provided so use the default
|
||||
# if edited file requested, use filename but add _edited
|
||||
# need to use file extension from edited file as Photos saves a jpeg once edited
|
||||
if edited and not use_photos_export:
|
||||
# verify we have a valid path_edited and use that to get filename
|
||||
if not self.path_edited:
|
||||
raise FileNotFoundError(
|
||||
"edited=True but path_edited is none; hasadjustments: "
|
||||
f" {self.hasadjustments}"
|
||||
)
|
||||
edited_name = pathlib.Path(self.path_edited).name
|
||||
edited_suffix = pathlib.Path(edited_name).suffix
|
||||
fname = pathlib.Path(self.filename).stem + "_edited" + edited_suffix
|
||||
else:
|
||||
fname = self.filename
|
||||
|
||||
# check destination path
|
||||
dest = pathlib.Path(dest)
|
||||
fname = pathlib.Path(fname)
|
||||
dest = dest / fname
|
||||
|
||||
# check extension of destination
|
||||
if edited and self.path_edited is not None:
|
||||
# use suffix from edited file
|
||||
actual_suffix = pathlib.Path(self.path_edited).suffix
|
||||
elif edited:
|
||||
# use .jpeg as that's probably correct
|
||||
# if edited and path_edited is None, will raise FileNotFoundError below
|
||||
# unless use_photos_export is True
|
||||
actual_suffix = ".jpeg"
|
||||
else:
|
||||
# use suffix from the non-edited file
|
||||
actual_suffix = pathlib.Path(self.filename).suffix
|
||||
|
||||
# warn if suffixes don't match but ignore .JPG / .jpeg as
|
||||
# Photo's often converts .JPG to .jpeg
|
||||
suffixes = sorted([x.lower() for x in [dest.suffix, actual_suffix]])
|
||||
if dest.suffix != actual_suffix and suffixes != [".jpeg", ".jpg"]:
|
||||
logging.warning(
|
||||
f"Invalid destination suffix: {dest.suffix}, should be {actual_suffix}"
|
||||
)
|
||||
|
||||
# check to see if file exists and if so, add (1), (2), etc until we find one that works
|
||||
# Photos checks the stem and adds (1), (2), etc which avoids collision with sidecars
|
||||
# e.g. exporting sidecar for file1.png and file1.jpeg
|
||||
# if file1.png exists and exporting file1.jpeg,
|
||||
# dest will be file1 (1).jpeg even though file1.jpeg doesn't exist to prevent sidecar collision
|
||||
if increment and not overwrite:
|
||||
count = 1
|
||||
glob_str = str(dest.parent / f"{dest.stem}*")
|
||||
dest_files = glob.glob(glob_str)
|
||||
dest_files = [pathlib.Path(f).stem for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
while dest_new in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
|
||||
# if overwrite==False and #increment==False, export should fail if file exists
|
||||
if dest.exists() and not overwrite and not increment:
|
||||
raise FileExistsError(
|
||||
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
|
||||
)
|
||||
|
||||
if not use_photos_export:
|
||||
# find the source file on disk and export
|
||||
# get path to source file and verify it's not None and is valid file
|
||||
# TODO: how to handle ismissing or not hasadjustments and edited=True cases?
|
||||
if edited:
|
||||
if self.path_edited is not None:
|
||||
src = self.path_edited
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"Cannot export edited photo if path_edited is None"
|
||||
)
|
||||
else:
|
||||
if self.ismissing:
|
||||
logging.warning(
|
||||
f"Attempting to export photo with ismissing=True: path = {self.path}"
|
||||
)
|
||||
|
||||
if self.path is not None:
|
||||
src = self.path
|
||||
else:
|
||||
raise FileNotFoundError("Cannot export photo if path is None")
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError(f"{src} does not appear to exist")
|
||||
|
||||
logging.debug(
|
||||
f"exporting {src} to {dest}, overwrite={overwrite}, increment={increment}, dest exists: {dest.exists()}"
|
||||
)
|
||||
|
||||
# copy the file, _copy_file uses ditto to preserve Mac extended attributes
|
||||
_copy_file(src, dest)
|
||||
exported_files.append(str(dest))
|
||||
|
||||
# copy live photo associated .mov if requested
|
||||
if live_photo and self.live_photo:
|
||||
live_name = dest.parent / f"{dest.stem}.mov"
|
||||
src_live = self.path_live_photo
|
||||
|
||||
if src_live is not None:
|
||||
logging.debug(
|
||||
f"Exporting live photo video of {filename} as {live_name.name}"
|
||||
)
|
||||
_copy_file(src_live, str(live_name))
|
||||
exported_files.append(str(live_name))
|
||||
else:
|
||||
logging.warning(f"Skipping missing live movie for {filename}")
|
||||
else:
|
||||
# use_photo_export
|
||||
exported = None
|
||||
# export live_photo .mov file?
|
||||
live_photo = True if live_photo and self.live_photo else False
|
||||
if edited:
|
||||
# exported edited version and not original
|
||||
if filename:
|
||||
# use filename stem provided
|
||||
filestem = dest.stem
|
||||
else:
|
||||
# didn't get passed a filename, add _edited
|
||||
filestem = f"{dest.stem}_edited"
|
||||
dest = dest.parent / f"{filestem}.jpeg"
|
||||
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
original=False,
|
||||
edited=True,
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
)
|
||||
else:
|
||||
# export original version and not edited
|
||||
filestem = dest.stem
|
||||
exported = _export_photo_uuid_applescript(
|
||||
self.uuid,
|
||||
dest.parent,
|
||||
filestem=filestem,
|
||||
original=True,
|
||||
edited=False,
|
||||
live_photo=live_photo,
|
||||
timeout=timeout,
|
||||
burst=self.burst,
|
||||
)
|
||||
|
||||
if exported is not None:
|
||||
exported_files.extend(exported)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Error exporting photo {self.uuid} to {dest} with use_photos_export"
|
||||
)
|
||||
|
||||
if sidecar_json:
|
||||
logging.debug("writing exiftool_json_sidecar")
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.json")
|
||||
sidecar_str = self._exiftool_json_sidecar()
|
||||
try:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error writing json sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
|
||||
if sidecar_xmp:
|
||||
logging.debug("writing xmp_sidecar")
|
||||
sidecar_filename = dest.parent / pathlib.Path(f"{dest.stem}.xmp")
|
||||
sidecar_str = self._xmp_sidecar()
|
||||
try:
|
||||
self._write_sidecar(sidecar_filename, sidecar_str)
|
||||
except Exception as e:
|
||||
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
|
||||
raise e
|
||||
|
||||
# if exiftool, write the metadata
|
||||
if exiftool and exported_files:
|
||||
for exported_file in exported_files:
|
||||
self._write_exif_data(exported_file)
|
||||
|
||||
return exported_files
|
||||
|
||||
def _write_exif_data(self, filepath):
|
||||
""" write exif data to image file at filepath
|
||||
filepath: full path to the image file """
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"Could not find file {filepath}")
|
||||
exiftool = ExifTool(filepath)
|
||||
exif_info = json.loads(self._exiftool_json_sidecar())[0]
|
||||
for exiftag, val in exif_info.items():
|
||||
if type(val) == list:
|
||||
# more than one, set first value the add additional values
|
||||
exiftool.setvalue(exiftag, val.pop(0))
|
||||
if val:
|
||||
# add any remaining items
|
||||
exiftool.addvalues(exiftag, *val)
|
||||
else:
|
||||
exiftool.setvalue(exiftag, val)
|
||||
|
||||
def _exiftool_json_sidecar(self):
|
||||
""" return json string of EXIF details in exiftool sidecar format
|
||||
Does not include all the EXIF fields as those are likely already in the image
|
||||
Exports the following:
|
||||
FileName
|
||||
ImageDescription
|
||||
Description
|
||||
Title
|
||||
TagsList
|
||||
Keywords
|
||||
Subject
|
||||
PersonInImage
|
||||
GPSLatitude, GPSLongitude
|
||||
GPSPosition
|
||||
GPSLatitudeRef, GPSLongitudeRef
|
||||
DateTimeOriginal
|
||||
OffsetTimeOriginal
|
||||
ModifyDate """
|
||||
|
||||
exif = {}
|
||||
exif["_CreatedBy"] = "osxphotos, https://github.com/RhetTbull/osxphotos"
|
||||
|
||||
if self.description:
|
||||
exif["EXIF:ImageDescription"] = self.description
|
||||
exif["XMP:Description"] = self.description
|
||||
|
||||
if self.title:
|
||||
exif["XMP:Title"] = self.title
|
||||
|
||||
if self.keywords:
|
||||
exif["XMP:TagsList"] = exif["IPTC:Keywords"] = list(self.keywords)
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
exif["XMP:Subject"] = list(self.keywords)
|
||||
|
||||
if self.persons:
|
||||
exif["XMP:PersonInImage"] = self.persons
|
||||
# Photos puts both keywords and persons in Subject when using "Export IPTC as XMP"
|
||||
if "XMP:Subject" in exif:
|
||||
exif["XMP:Subject"].extend(self.persons)
|
||||
else:
|
||||
exif["XMP:Subject"] = self.persons
|
||||
|
||||
# if self.favorite():
|
||||
# exif["Rating"] = 5
|
||||
|
||||
(lat, lon) = self.location
|
||||
if lat is not None and lon is not None:
|
||||
lat_str, lon_str = dd_to_dms_str(lat, lon)
|
||||
exif["EXIF:GPSLatitude"] = lat_str
|
||||
exif["EXIF:GPSLongitude"] = lon_str
|
||||
exif["Composite:GPSPosition"] = f"{lat_str}, {lon_str}"
|
||||
lat_ref = "North" if lat >= 0 else "South"
|
||||
lon_ref = "East" if lon >= 0 else "West"
|
||||
exif["EXIF:GPSLatitudeRef"] = lat_ref
|
||||
exif["EXIF:GPSLongitudeRef"] = lon_ref
|
||||
|
||||
# process date/time and timezone offset
|
||||
date = self.date
|
||||
# exiftool expects format to "2015:01:18 12:00:00"
|
||||
datetimeoriginal = date.strftime("%Y:%m:%d %H:%M:%S")
|
||||
offsettime = date.strftime("%z")
|
||||
# find timezone offset in format "-04:00"
|
||||
offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
|
||||
offset = offset[0] # findall returns list of tuples
|
||||
offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
|
||||
exif["EXIF:DateTimeOriginal"] = datetimeoriginal
|
||||
exif["EXIF:OffsetTimeOriginal"] = offsettime
|
||||
|
||||
if self.date_modified is not None:
|
||||
exif["EXIF:ModifyDate"] = self.date_modified.strftime("%Y:%m:%d %H:%M:%S")
|
||||
|
||||
json_str = json.dumps([exif])
|
||||
return json_str
|
||||
|
||||
def _xmp_sidecar(self):
|
||||
""" returns string for XMP sidecar """
|
||||
# TODO: add additional fields to XMP file?
|
||||
|
||||
xmp_template = Template(
|
||||
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)
|
||||
)
|
||||
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):
|
||||
""" Returns longitude, in degrees """
|
||||
return self._info["longitude"]
|
||||
|
||||
@property
|
||||
def _latitude(self):
|
||||
""" Returns latitude, in degrees """
|
||||
return self._info["latitude"]
|
||||
|
||||
def __repr__(self):
|
||||
return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})"
|
||||
|
||||
def __str__(self):
|
||||
""" string representation of PhotoInfo object """
|
||||
|
||||
date_iso = self.date.isoformat()
|
||||
date_modified_iso = (
|
||||
self.date_modified.isoformat() if self.date_modified else None
|
||||
)
|
||||
|
||||
info = {
|
||||
"uuid": self.uuid,
|
||||
"filename": self.filename,
|
||||
"original_filename": self.original_filename,
|
||||
"date": date_iso,
|
||||
"description": self.description,
|
||||
"title": self.title,
|
||||
"keywords": self.keywords,
|
||||
"albums": self.albums,
|
||||
"persons": self.persons,
|
||||
"path": self.path,
|
||||
"ismissing": self.ismissing,
|
||||
"hasadjustments": self.hasadjustments,
|
||||
"external_edit": self.external_edit,
|
||||
"favorite": self.favorite,
|
||||
"hidden": self.hidden,
|
||||
"latitude": self._latitude,
|
||||
"longitude": self._longitude,
|
||||
"path_edited": self.path_edited,
|
||||
"shared": self.shared,
|
||||
"isphoto": self.isphoto,
|
||||
"ismovie": self.ismovie,
|
||||
"uti": self.uti,
|
||||
"burst": self.burst,
|
||||
"live_photo": self.live_photo,
|
||||
"path_live_photo": self.path_live_photo,
|
||||
"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,
|
||||
}
|
||||
return yaml.dump(info, sort_keys=False)
|
||||
|
||||
def json(self):
|
||||
""" return JSON representation """
|
||||
|
||||
date_modified_iso = (
|
||||
self.date_modified.isoformat() if self.date_modified else None
|
||||
)
|
||||
|
||||
pic = {
|
||||
"uuid": self.uuid,
|
||||
"filename": self.filename,
|
||||
"original_filename": self.original_filename,
|
||||
"date": self.date.isoformat(),
|
||||
"description": self.description,
|
||||
"title": self.title,
|
||||
"keywords": self.keywords,
|
||||
"albums": self.albums,
|
||||
"persons": self.persons,
|
||||
"path": self.path,
|
||||
"ismissing": self.ismissing,
|
||||
"hasadjustments": self.hasadjustments,
|
||||
"external_edit": self.external_edit,
|
||||
"favorite": self.favorite,
|
||||
"hidden": self.hidden,
|
||||
"latitude": self._latitude,
|
||||
"longitude": self._longitude,
|
||||
"path_edited": self.path_edited,
|
||||
"shared": self.shared,
|
||||
"isphoto": self.isphoto,
|
||||
"ismovie": self.ismovie,
|
||||
"uti": self.uti,
|
||||
"burst": self.burst,
|
||||
"live_photo": self.live_photo,
|
||||
"path_live_photo": self.path_live_photo,
|
||||
"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,
|
||||
}
|
||||
return json.dumps(pic)
|
||||
|
||||
# compare two PhotoInfo objects for equality
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, self.__class__):
|
||||
return self.__dict__ == other.__dict__
|
||||
|
||||
return False
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
2144
osxphotos/photosdb.py
Normal file
2144
osxphotos/photosdb.py
Normal file
File diff suppressed because it is too large
Load Diff
626
osxphotos/placeinfo.py
Normal file
626
osxphotos/placeinfo.py
Normal file
@@ -0,0 +1,626 @@
|
||||
"""
|
||||
PlaceInfo class
|
||||
Provides reverse geolocation info for photos
|
||||
|
||||
See https://developer.apple.com/documentation/corelocation/clplacemark
|
||||
for additional documentation on reverse geolocation data
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import namedtuple # pylint: disable=syntax-error
|
||||
|
||||
import yaml
|
||||
from bpylist import archiver
|
||||
|
||||
# postal address information, returned by PlaceInfo.address
|
||||
PostalAddress = namedtuple(
|
||||
"PostalAddress",
|
||||
[
|
||||
"street",
|
||||
"sub_locality",
|
||||
"city",
|
||||
"sub_administrative_area",
|
||||
"state_province",
|
||||
"postal_code",
|
||||
"country",
|
||||
"iso_country_code",
|
||||
],
|
||||
)
|
||||
|
||||
# PlaceNames tuple returned by PlaceInfo.names
|
||||
# order of fields 0 - 17 is mapped to placeType value in
|
||||
# PLRevGeoLocationInfo.mapInfo.sortedPlaceInfos
|
||||
# field 18 is combined bodies of water (ocean + inland_water)
|
||||
# and maps to Photos <= 4, RKPlace.type == 44
|
||||
# (Photos <= 4 doesn't have ocean or inland_water types)
|
||||
# The fields named "field0", etc. appear to be unused
|
||||
PlaceNames = namedtuple(
|
||||
"PlaceNames",
|
||||
[
|
||||
"field0",
|
||||
"country", # The name of the country associated with the placemark.
|
||||
"state_province", # administrativeArea, The state or province associated with the placemark.
|
||||
"sub_administrative_area", # Additional administrative area information for the placemark.
|
||||
"city", # locality, The city associated with the placemark.
|
||||
"field5",
|
||||
"additional_city_info", # subLocality, Additional city-level information for the placemark.
|
||||
"ocean", # The name of the ocean associated with the placemark.
|
||||
"area_of_interest", # areasOfInterest, The relevant areas of interest associated with the placemark.
|
||||
"inland_water", # The name of the inland water body associated with the placemark.
|
||||
"field10",
|
||||
"region", # The geographic region associated with the placemark.
|
||||
"sub_throughfare", # Additional street-level information for the placemark.
|
||||
"field13",
|
||||
"postal_code", # The postal code associated with the placemark.
|
||||
"field15",
|
||||
"field16",
|
||||
"street_address", # throughfare, The street address associated with the placemark.
|
||||
"body_of_water", # RKPlace.type == 44, appears to be any body of water (ocean or inland)
|
||||
],
|
||||
)
|
||||
|
||||
# The following classes represent Photo Library Reverse Geolocation Info as stored
|
||||
# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
|
||||
# These classes are used by bpylist.archiver to unarchive the serialized objects
|
||||
class PLRevGeoLocationInfo:
|
||||
""" The top level reverse geolocation object """
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
addressString,
|
||||
countryCode,
|
||||
mapItem,
|
||||
isHome,
|
||||
compoundNames,
|
||||
compoundSecondaryNames,
|
||||
version,
|
||||
geoServiceProvider,
|
||||
postalAddress,
|
||||
):
|
||||
self.addressString = addressString
|
||||
self.countryCode = countryCode
|
||||
self.mapItem = mapItem
|
||||
self.isHome = isHome
|
||||
self.compoundNames = compoundNames
|
||||
self.compoundSecondaryNames = compoundSecondaryNames
|
||||
self.version = version
|
||||
self.geoServiceProvider = geoServiceProvider
|
||||
self.postalAddress = postalAddress
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in [
|
||||
"addressString",
|
||||
"countryCode",
|
||||
"isHome",
|
||||
"compoundNames",
|
||||
"compoundSecondaryNames",
|
||||
"version",
|
||||
"geoServiceProvider",
|
||||
"postalAddress",
|
||||
]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
return f"addressString: {self.addressString}, countryCode: {self.countryCode}, isHome: {self.isHome}, mapItem: {self.mapItem}, postalAddress: {self.postalAddress}"
|
||||
|
||||
@staticmethod
|
||||
def encode_archive(obj, archive):
|
||||
archive.encode("addressString", obj.addressString)
|
||||
archive.encode("countryCode", obj.countryCode)
|
||||
archive.encode("mapItem", obj.mapItem)
|
||||
archive.encode("isHome", obj.isHome)
|
||||
archive.encode("compoundNames", obj.compoundNames)
|
||||
archive.encode("compoundSecondaryNames", obj.compoundSecondaryNames)
|
||||
archive.encode("version", obj.version)
|
||||
archive.encode("geoServiceProvider", obj.geoServiceProvider)
|
||||
archive.encode("postalAddress", obj.postalAddress)
|
||||
|
||||
@staticmethod
|
||||
def decode_archive(archive):
|
||||
addressString = archive.decode("addressString")
|
||||
countryCode = archive.decode("countryCode")
|
||||
mapItem = archive.decode("mapItem")
|
||||
isHome = archive.decode("isHome")
|
||||
compoundNames = archive.decode("compoundNames")
|
||||
compoundSecondaryNames = archive.decode("compoundSecondaryNames")
|
||||
version = archive.decode("version")
|
||||
geoServiceProvider = archive.decode("geoServiceProvider")
|
||||
postalAddress = archive.decode("postalAddress")
|
||||
return PLRevGeoLocationInfo(
|
||||
addressString,
|
||||
countryCode,
|
||||
mapItem,
|
||||
isHome,
|
||||
compoundNames,
|
||||
compoundSecondaryNames,
|
||||
version,
|
||||
geoServiceProvider,
|
||||
postalAddress,
|
||||
)
|
||||
|
||||
|
||||
class PLRevGeoMapItem:
|
||||
""" Stores the list of place names, organized by area """
|
||||
|
||||
def __init__(self, sortedPlaceInfos, finalPlaceInfos):
|
||||
self.sortedPlaceInfos = sortedPlaceInfos
|
||||
self.finalPlaceInfos = finalPlaceInfos
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in ["sortedPlaceInfos", "finalPlaceInfos"]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
sortedPlaceInfos = []
|
||||
finalPlaceInfos = []
|
||||
for place in self.sortedPlaceInfos:
|
||||
sortedPlaceInfos.append(str(place))
|
||||
for place in self.finalPlaceInfos:
|
||||
finalPlaceInfos.append(str(place))
|
||||
return (
|
||||
f"finalPlaceInfos: {finalPlaceInfos}, sortedPlaceInfos: {sortedPlaceInfos}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def encode_archive(obj, archive):
|
||||
archive.encode("sortedPlaceInfos", obj.sortedPlaceInfos)
|
||||
archive.encode("finalPlaceInfos", obj.finalPlaceInfos)
|
||||
|
||||
@staticmethod
|
||||
def decode_archive(archive):
|
||||
sortedPlaceInfos = archive.decode("sortedPlaceInfos")
|
||||
finalPlaceInfos = archive.decode("finalPlaceInfos")
|
||||
return PLRevGeoMapItem(sortedPlaceInfos, finalPlaceInfos)
|
||||
|
||||
|
||||
class PLRevGeoMapItemAdditionalPlaceInfo:
|
||||
""" Additional info about individual places """
|
||||
|
||||
def __init__(self, area, name, placeType, dominantOrderType):
|
||||
self.area = area
|
||||
self.name = name
|
||||
self.placeType = placeType
|
||||
self.dominantOrderType = dominantOrderType
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in ["area", "name", "placeType", "dominantOrderType"]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
return f"area: {self.area}, name: {self.name}, placeType: {self.placeType}"
|
||||
|
||||
@staticmethod
|
||||
def encode_archive(obj, archive):
|
||||
archive.encode("area", obj.area)
|
||||
archive.encode("name", obj.name)
|
||||
archive.encode("placeType", obj.placeType)
|
||||
archive.encode("dominantOrderType", obj.dominantOrderType)
|
||||
|
||||
@staticmethod
|
||||
def decode_archive(archive):
|
||||
area = archive.decode("area")
|
||||
name = archive.decode("name")
|
||||
placeType = archive.decode("placeType")
|
||||
dominantOrderType = archive.decode("dominantOrderType")
|
||||
return PLRevGeoMapItemAdditionalPlaceInfo(
|
||||
area, name, placeType, dominantOrderType
|
||||
)
|
||||
|
||||
|
||||
class CNPostalAddress:
|
||||
""" postal address for the reverse geolocation info """
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
_ISOCountryCode,
|
||||
_city,
|
||||
_country,
|
||||
_postalCode,
|
||||
_state,
|
||||
_street,
|
||||
_subAdministrativeArea,
|
||||
_subLocality,
|
||||
):
|
||||
self._ISOCountryCode = _ISOCountryCode
|
||||
self._city = _city
|
||||
self._country = _country
|
||||
self._postalCode = _postalCode
|
||||
self._state = _state
|
||||
self._street = _street
|
||||
self._subAdministrativeArea = _subAdministrativeArea
|
||||
self._subLocality = _subLocality
|
||||
|
||||
def __eq__(self, other):
|
||||
for field in [
|
||||
"_ISOCountryCode",
|
||||
"_city",
|
||||
"_country",
|
||||
"_postalCode",
|
||||
"_state",
|
||||
"_street",
|
||||
"_subAdministrativeArea",
|
||||
"_subLocality",
|
||||
]:
|
||||
if getattr(self, field) != getattr(other, field):
|
||||
return False
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
return ", ".join(
|
||||
map(
|
||||
str,
|
||||
[
|
||||
self._street,
|
||||
self._city,
|
||||
self._subLocality,
|
||||
self._subAdministrativeArea,
|
||||
self._state,
|
||||
self._postalCode,
|
||||
self._country,
|
||||
self._ISOCountryCode,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def encode_archive(obj, archive):
|
||||
archive.encode("_ISOCountryCode", obj._ISOCountryCode)
|
||||
archive.encode("_country", obj._country)
|
||||
archive.encode("_city", obj._city)
|
||||
archive.encode("_postalCode", obj._postalCode)
|
||||
archive.encode("_state", obj._state)
|
||||
archive.encode("_street", obj._street)
|
||||
archive.encode("_subAdministrativeArea", obj._subAdministrativeArea)
|
||||
archive.encode("_subLocality", obj._subLocality)
|
||||
|
||||
@staticmethod
|
||||
def decode_archive(archive):
|
||||
_ISOCountryCode = archive.decode("_ISOCountryCode")
|
||||
_country = archive.decode("_country")
|
||||
_city = archive.decode("_city")
|
||||
_postalCode = archive.decode("_postalCode")
|
||||
_state = archive.decode("_state")
|
||||
_street = archive.decode("_street")
|
||||
_subAdministrativeArea = archive.decode("_subAdministrativeArea")
|
||||
_subLocality = archive.decode("_subLocality")
|
||||
|
||||
return CNPostalAddress(
|
||||
_ISOCountryCode,
|
||||
_city,
|
||||
_country,
|
||||
_postalCode,
|
||||
_state,
|
||||
_street,
|
||||
_subAdministrativeArea,
|
||||
_subLocality,
|
||||
)
|
||||
|
||||
|
||||
# register the classes with bpylist.archiver
|
||||
archiver.update_class_map({"CNPostalAddress": CNPostalAddress})
|
||||
archiver.update_class_map(
|
||||
{"PLRevGeoMapItemAdditionalPlaceInfo": PLRevGeoMapItemAdditionalPlaceInfo}
|
||||
)
|
||||
archiver.update_class_map({"PLRevGeoMapItem": PLRevGeoMapItem})
|
||||
archiver.update_class_map({"PLRevGeoLocationInfo": PLRevGeoLocationInfo})
|
||||
|
||||
|
||||
class PlaceInfo(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def address_str(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def country_code(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def ishome(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def names(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def address(self):
|
||||
pass
|
||||
|
||||
|
||||
class PlaceInfo4(PlaceInfo):
|
||||
""" Reverse geolocation place info for a photo (Photos <= 4) """
|
||||
|
||||
def __init__(self, place_names, country_code):
|
||||
""" place_names: list of place name tuples in ascending order by area
|
||||
tuple fields are: modelID, place name, place type, area, e.g.
|
||||
[(5, "St James's Park", 45, 0),
|
||||
(4, 'Westminster', 16, 22097376),
|
||||
(3, 'London', 4, 1596146816),
|
||||
(2, 'England', 2, 180406091776),
|
||||
(1, 'United Kingdom', 1, 414681432064)]
|
||||
country_code: two letter country code for the country
|
||||
"""
|
||||
self._place_names = place_names
|
||||
self._country_code = country_code
|
||||
self._process_place_info()
|
||||
|
||||
@property
|
||||
def address_str(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def country_code(self):
|
||||
return self._country_code
|
||||
|
||||
@property
|
||||
def ishome(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
return self._names
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
return PostalAddress(None, None, None, None, None, None, None, None)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, type(self)):
|
||||
return False
|
||||
else:
|
||||
return (
|
||||
self._place_names == other._place_names
|
||||
and self._country_code == other._country_code
|
||||
)
|
||||
|
||||
def _process_place_info(self):
|
||||
""" Process place_names to set self._name and self._names """
|
||||
places = self._place_names
|
||||
|
||||
# build a dictionary where key is placetype
|
||||
places_dict = {}
|
||||
for p in places:
|
||||
# places in format:
|
||||
# [(5, "St James's Park", 45, 0), ]
|
||||
# 0: modelID
|
||||
# 1: name
|
||||
# 2: type
|
||||
# 3: area
|
||||
try:
|
||||
places_dict[p[2]].append((p[1], p[3]))
|
||||
except KeyError:
|
||||
places_dict[p[2]] = [(p[1], p[3])]
|
||||
|
||||
# build list to populate PlaceNames tuple
|
||||
# initialize with empty lists for each field in PlaceNames
|
||||
place_info = [[]] * 19
|
||||
|
||||
# add the place names sorted by area (ascending)
|
||||
# in Photos <=4, possible place type values are:
|
||||
# 45: areasOfInterest (The relevant areas of interest associated with the placemark.)
|
||||
# 44: body of water (includes both inlandWater and ocean)
|
||||
# 43: subLocality (Additional city-level information for the placemark.
|
||||
# 16: locality (The city associated with the placemark.)
|
||||
# 4: subAdministrativeArea (Additional administrative area information for the placemark.)
|
||||
# 2: administrativeArea (The state or province associated with the placemark.)
|
||||
# 1: country
|
||||
# mapping = mapping from PlaceNames to field in places_dict
|
||||
# PlaceNames fields map to the placeType value in Photos5 (0..17)
|
||||
# but place type in Photos <=4 has different values
|
||||
# hence (3, 4) means PlaceNames[3] = places_dict[4] (sub_administrative_area)
|
||||
mapping = [(1, 1), (2, 2), (3, 4), (4, 16), (18, 44), (8, 45)]
|
||||
for field5, field4 in mapping:
|
||||
try:
|
||||
place_info[field5] = [
|
||||
p[0]
|
||||
for p in sorted(places_dict[field4], key=lambda place: place[1])
|
||||
]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
place_names = PlaceNames(*place_info)
|
||||
self._names = place_names
|
||||
|
||||
# build the name as it appears in Photos
|
||||
# the length of the name is at most 3 fields and appears to be based on available
|
||||
# reverse geolocation data in the following order (left to right, joined by ',')
|
||||
# always has country if available then either area of interest and city OR
|
||||
# city and state
|
||||
# e.g. 4, 2, 1 OR 8, 4, 1
|
||||
# 8 (45): area_of_interest
|
||||
# 4 (16): locality / city
|
||||
# 2 (2): administrative area (state/province)
|
||||
# 1 (1): country
|
||||
name_list = []
|
||||
if place_names[8]:
|
||||
name_list.append(place_names[8][0])
|
||||
if place_names[4]:
|
||||
name_list.append(place_names[4][0])
|
||||
elif place_names[4]:
|
||||
name_list.append(place_names[4][0])
|
||||
if place_names[2]:
|
||||
name_list.append(place_names[2][0])
|
||||
elif place_names[2]:
|
||||
name_list.append(place_names[2][0])
|
||||
|
||||
# add country
|
||||
if place_names[1]:
|
||||
name_list.append(place_names[1][0])
|
||||
|
||||
name = ", ".join(name_list)
|
||||
self._name = name if name != "" else None
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
info = {
|
||||
"name": self.name,
|
||||
"names": self.names,
|
||||
"country_code": self.country_code,
|
||||
}
|
||||
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
return strval
|
||||
|
||||
|
||||
class PlaceInfo5(PlaceInfo):
|
||||
""" Reverse geolocation place info for a photo (Photos >= 5) """
|
||||
|
||||
def __init__(self, revgeoloc_bplist):
|
||||
""" revgeoloc_bplist: a binary plist blob containing
|
||||
a serialized PLRevGeoLocationInfo object """
|
||||
self._bplist = revgeoloc_bplist
|
||||
# todo: check for None?
|
||||
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
|
||||
self._process_place_info()
|
||||
|
||||
@property
|
||||
def address_str(self):
|
||||
""" returns the postal address as a string """
|
||||
return self._plrevgeoloc.addressString
|
||||
|
||||
@property
|
||||
def country_code(self):
|
||||
""" returns the country code """
|
||||
return self._plrevgeoloc.countryCode
|
||||
|
||||
@property
|
||||
def ishome(self):
|
||||
""" returns True if place is user's home address """
|
||||
return self._plrevgeoloc.isHome
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" returns local place name """
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
""" returns PlaceNames tuple with detailed reverse geolocation place names """
|
||||
return self._names
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
addr = self._plrevgeoloc.postalAddress
|
||||
address = PostalAddress(
|
||||
street=addr._street,
|
||||
sub_locality=addr._subLocality,
|
||||
city=addr._city,
|
||||
sub_administrative_area=addr._subAdministrativeArea,
|
||||
state_province=addr._state,
|
||||
postal_code=addr._postalCode,
|
||||
country=addr._country,
|
||||
iso_country_code=addr._ISOCountryCode,
|
||||
)
|
||||
return address
|
||||
|
||||
def _process_place_info(self):
|
||||
""" Process sortedPlaceInfos to set self._name and self._names """
|
||||
places = self._plrevgeoloc.mapItem.sortedPlaceInfos
|
||||
|
||||
# build a dictionary where key is placetype
|
||||
places_dict = {}
|
||||
for p in places:
|
||||
try:
|
||||
places_dict[p.placeType].append((p.name, p.area))
|
||||
except KeyError:
|
||||
places_dict[p.placeType] = [(p.name, p.area)]
|
||||
|
||||
# build list to populate PlaceNames tuple
|
||||
place_info = []
|
||||
for field in range(18):
|
||||
try:
|
||||
# add the place names sorted by area (ascending)
|
||||
place_info.append(
|
||||
[
|
||||
p[0]
|
||||
for p in sorted(places_dict[field], key=lambda place: place[1])
|
||||
]
|
||||
)
|
||||
except:
|
||||
place_info.append([])
|
||||
|
||||
# fill in body_of_water for compatibility with Photos <= 4
|
||||
place_info.append(place_info[7] + place_info[9])
|
||||
|
||||
place_names = PlaceNames(*place_info)
|
||||
self._names = place_names
|
||||
|
||||
# build the name as it appears in Photos
|
||||
# the length of the name is variable and appears to be based on available
|
||||
# reverse geolocation data in the following order (left to right, joined by ',')
|
||||
# 8: area_of_interest
|
||||
# 11: region (I've only seen this applied to islands)
|
||||
# 4: locality / city
|
||||
# 2: administrative area (state/province)
|
||||
# 1: country
|
||||
# 9: inland_water
|
||||
# 7: ocean
|
||||
name = ", ".join(
|
||||
[
|
||||
p[0]
|
||||
for p in [
|
||||
place_names[8], # area of interest
|
||||
place_names[11], # region (I've only seen this applied to islands)
|
||||
place_names[4], # locality / city
|
||||
place_names[2], # administrative area (state/province)
|
||||
place_names[1], # country
|
||||
place_names[9], # inland_water
|
||||
place_names[7], # ocean
|
||||
]
|
||||
if p and p[0]
|
||||
]
|
||||
)
|
||||
self._name = name if name != "" else None
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, type(self)):
|
||||
return False
|
||||
else:
|
||||
return self._plrevgeoloc == other._plrevgeoloc
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
info = {
|
||||
"name": self.name,
|
||||
"names": self.names,
|
||||
"country_code": self.country_code,
|
||||
"ishome": self.ishome,
|
||||
"address_str": self.address_str,
|
||||
"address": str(self.address),
|
||||
}
|
||||
strval = "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
return strval
|
||||
320
osxphotos/template.py
Normal file
320
osxphotos/template.py
Normal file
@@ -0,0 +1,320 @@
|
||||
import datetime
|
||||
import pathlib
|
||||
import re
|
||||
from typing import Tuple # pylint: disable=syntax-error
|
||||
|
||||
from .photoinfo import PhotoInfo
|
||||
|
||||
TEMPLATE_SUBSTITUTIONS = {
|
||||
"{name}": "Filename of the photo",
|
||||
"{original_name}": "Photo's original filename when imported to Photos",
|
||||
"{title}": "Title of the photo",
|
||||
"{descr}": "Description of the photo",
|
||||
"{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'",
|
||||
"{created.year}": "4-digit year of file creation time",
|
||||
"{created.yy}": "2-digit year of file creation time",
|
||||
"{created.mm}": "2-digit month of the file creation time (zero padded)",
|
||||
"{created.month}": "Month name in user's locale of the file creation time",
|
||||
"{created.mon}": "Month abbreviation in the user's locale of the file creation time",
|
||||
"{created.doy}": "3-digit day of year (e.g Julian day) of file creation time, starting from 1 (zero padded)",
|
||||
"{modified.date}": "Photo's modification date in ISO format, e.g. '2020-03-22'",
|
||||
"{modified.year}": "4-digit year of file modification time",
|
||||
"{modified.yy}": "2-digit year of file modification time",
|
||||
"{modified.mm}": "2-digit month of the file modification time (zero padded)",
|
||||
"{modified.month}": "Month name in user's locale of the file modification time",
|
||||
"{modified.mon}": "Month abbreviation in the user's locale of the file modification time",
|
||||
"{modified.doy}": "3-digit day of year (e.g Julian day) of file modification time, starting from 1 (zero padded)",
|
||||
"{place.name}": "Place name from the photo's reverse geolocation data, as displayed in Photos",
|
||||
"{place.country_code}": "The ISO country code from the photo's reverse geolocationo 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'",
|
||||
}
|
||||
|
||||
|
||||
def get_template_value(lookup, photo):
|
||||
""" lookup: value to find a match for
|
||||
photo: PhotoInfo object whose data will be used for value substitutions
|
||||
returns: either the matching template value (which may be None)
|
||||
raises: KeyError if no rule exists for lookup """
|
||||
|
||||
# must be a valid keyword
|
||||
if lookup == "name":
|
||||
return pathlib.Path(photo.filename).stem
|
||||
|
||||
if lookup == "original_name":
|
||||
return pathlib.Path(photo.original_filename).stem
|
||||
|
||||
if lookup == "title":
|
||||
return photo.title
|
||||
|
||||
if lookup == "descr":
|
||||
return photo.description
|
||||
|
||||
if lookup == "created.date":
|
||||
return DateTimeFormatter(photo.date).date
|
||||
|
||||
if lookup == "created.year":
|
||||
return DateTimeFormatter(photo.date).year
|
||||
|
||||
if lookup == "created.yy":
|
||||
return DateTimeFormatter(photo.date).yy
|
||||
|
||||
if lookup == "created.mm":
|
||||
return DateTimeFormatter(photo.date).mm
|
||||
|
||||
if lookup == "created.month":
|
||||
return DateTimeFormatter(photo.date).month
|
||||
|
||||
if lookup == "created.mon":
|
||||
return DateTimeFormatter(photo.date).mon
|
||||
|
||||
if lookup == "created.doy":
|
||||
return DateTimeFormatter(photo.date).doy
|
||||
|
||||
if lookup == "modified.date":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).date if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.year":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).year if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.yy":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).yy if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.mm":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).mm if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.month":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).month
|
||||
if photo.date_modified
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "modified.mon":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).mon if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "modified.doy":
|
||||
return (
|
||||
DateTimeFormatter(photo.date_modified).doy if photo.date_modified else None
|
||||
)
|
||||
|
||||
if lookup == "place.name":
|
||||
return photo.place.name if photo.place else None
|
||||
|
||||
if lookup == "place.country_code":
|
||||
return photo.place.country_code if photo.place else None
|
||||
|
||||
if lookup == "place.name.country":
|
||||
return (
|
||||
photo.place.names.country[0]
|
||||
if photo.place and photo.place.names.country
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.name.state_province":
|
||||
return (
|
||||
photo.place.names.state_province[0]
|
||||
if photo.place and photo.place.names.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.name.city":
|
||||
return (
|
||||
photo.place.names.city[0]
|
||||
if photo.place and photo.place.names.city
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.name.area_of_interest":
|
||||
return (
|
||||
photo.place.names.area_of_interest[0]
|
||||
if photo.place and photo.place.names.area_of_interest
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address":
|
||||
return (
|
||||
photo.place.address_str if photo.place and photo.place.address_str else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.street":
|
||||
return (
|
||||
photo.place.address.street
|
||||
if photo.place and photo.place.address.street
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.city":
|
||||
return (
|
||||
photo.place.address.city
|
||||
if photo.place and photo.place.address.city
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.state_province":
|
||||
return (
|
||||
photo.place.address.state_province
|
||||
if photo.place and photo.place.address.state_province
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.postal_code":
|
||||
return (
|
||||
photo.place.address.postal_code
|
||||
if photo.place and photo.place.address.postal_code
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.country":
|
||||
return (
|
||||
photo.place.address.country
|
||||
if photo.place and photo.place.address.country
|
||||
else None
|
||||
)
|
||||
|
||||
if lookup == "place.address.country_code":
|
||||
return (
|
||||
photo.place.address.iso_country_code
|
||||
if photo.place and photo.place.address.iso_country_code
|
||||
else None
|
||||
)
|
||||
|
||||
# if here, didn't get a match
|
||||
raise KeyError(f"No rule for processing {lookup}")
|
||||
|
||||
|
||||
def render_filepath_template(
|
||||
template: str, photo: PhotoInfo, none_str: str = "_"
|
||||
) -> Tuple[str, list]:
|
||||
""" render a filename or directory template """
|
||||
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
regex = r"""(?<!\\)\{([^\\,}]+)(,{0,1}(([\w\-. ]+))?)\}"""
|
||||
|
||||
# pylint: disable=anomalous-backslash-in-string
|
||||
unmatched_regex = r"(?<!\\)(\{[^\\,}]+\})"
|
||||
|
||||
# Explanation for regex:
|
||||
# (?<!\\) Negative Lookbehind to skip escaped braces
|
||||
# assert regex following does not match "\" preceeding "{"
|
||||
# \{ Match the opening brace
|
||||
# 1st Capturing Group ([^\\,}]+) Don't match "\", ",", or "}"
|
||||
# 2nd Capturing Group (,?(([\w\-. ]+))?)
|
||||
# ,{0,1} optional ","
|
||||
# 3rd Capturing Group (([\w\-. ]+))?
|
||||
# Matches the comma and any word characters after
|
||||
# 4th Capturing Group ([\w\-. ]+)
|
||||
# Matches just the characters after the comma
|
||||
# \} Matches the closing brace
|
||||
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
if type(photo) is not PhotoInfo:
|
||||
raise TypeError(f"photo must be type osxphotos.PhotoInfo, not {type(photo)}")
|
||||
|
||||
def make_subst_function(photo, none_str):
|
||||
""" returns: substitution function for use in re.sub """
|
||||
# closure to capture photo, none_str in subst
|
||||
def subst(matchobj):
|
||||
groups = len(matchobj.groups())
|
||||
if groups == 4:
|
||||
try:
|
||||
val = get_template_value(matchobj.group(1), photo)
|
||||
except KeyError:
|
||||
return matchobj.group(0)
|
||||
|
||||
if val is None:
|
||||
return (
|
||||
matchobj.group(3) if matchobj.group(3) is not None else none_str
|
||||
)
|
||||
else:
|
||||
return val
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected number of groups: expected 4, got {groups}"
|
||||
)
|
||||
|
||||
return subst
|
||||
|
||||
subst_func = make_subst_function(photo, none_str)
|
||||
|
||||
# do the replacements
|
||||
rendered = re.sub(regex, subst_func, template)
|
||||
|
||||
# find any {words} that weren't replaced
|
||||
unmatched = re.findall(unmatched_regex, rendered)
|
||||
|
||||
# fix any escaped curly braces
|
||||
rendered = re.sub(r"\\{", "{", rendered)
|
||||
rendered = re.sub(r"\\}", "}", rendered)
|
||||
|
||||
return rendered, unmatched
|
||||
|
||||
|
||||
class DateTimeFormatter:
|
||||
""" provides property access to formatted datetime.datetime strftime values """
|
||||
|
||||
def __init__(self, dt: datetime.datetime):
|
||||
self.dt = dt
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
""" ISO date in form 2020-03-22 """
|
||||
date = self.dt.date().isoformat()
|
||||
return date
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" 4 digit year """
|
||||
year = f"{self.dt.year}"
|
||||
return year
|
||||
|
||||
@property
|
||||
def yy(self):
|
||||
""" 2 digit year """
|
||||
yy = f"{self.dt.strftime('%y')}"
|
||||
return yy
|
||||
|
||||
@property
|
||||
def mm(self):
|
||||
""" 2 digit month """
|
||||
mm = f"{self.dt.strftime('%m')}"
|
||||
return mm
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" Month as locale's full name """
|
||||
month = f"{self.dt.strftime('%B')}"
|
||||
return month
|
||||
|
||||
@property
|
||||
def mon(self):
|
||||
""" Month as locale's abbreviated name """
|
||||
mon = f"{self.dt.strftime('%b')}"
|
||||
return mon
|
||||
|
||||
@property
|
||||
def doy(self):
|
||||
""" Julian day of year starting from 001 """
|
||||
doy = f"{self.dt.strftime('%j')}"
|
||||
return doy
|
||||
99
osxphotos/templates/xmp_sidecar.mako
Normal file
99
osxphotos/templates/xmp_sidecar.mako
Normal file
@@ -0,0 +1,99 @@
|
||||
<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
|
||||
<%def name="dc_description(desc)">
|
||||
% if desc is None:
|
||||
<dc:description></dc:description>
|
||||
% else:
|
||||
<dc:description>${desc}</dc:description>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_title(title)">
|
||||
% if title is None:
|
||||
<dc:title></dc:title>
|
||||
% else:
|
||||
<dc:title>${title}</dc:title>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_subject(subject)">
|
||||
% if subject:
|
||||
<!-- keywords and persons listed in <dc:subject> as Photos does -->
|
||||
<dc:subject>
|
||||
<rdf:Seq>
|
||||
% for subj in subject:
|
||||
<rdf:li>${subj}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_datecreated(date)">
|
||||
% if date is not None:
|
||||
<photoshop:DateCreated>${date.isoformat()}</photoshop:DateCreated>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="iptc_personinimage(persons)">
|
||||
% if persons:
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
% for person in persons:
|
||||
<rdf:li>${person}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dk_tagslist(keywords)">
|
||||
% if keywords:
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
% for keyword in keywords:
|
||||
<rdf:li>${keyword}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="adobe_createdate(date)">
|
||||
% if date is not None:
|
||||
<xmp:CreateDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:CreateDate>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="adobe_modifydate(date)">
|
||||
% if date is not None:
|
||||
<xmp:ModifyDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:ModifyDate>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
|
||||
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||
${dc_description(photo.description)}
|
||||
${dc_title(photo.title)}
|
||||
${dc_subject(photo.keywords + photo.persons)}
|
||||
${dc_datecreated(photo.date)}
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
|
||||
${iptc_personinimage(photo.persons)}
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
|
||||
${dk_tagslist(photo.keywords)}
|
||||
</rdf:Description>
|
||||
<rdf:Description rdf:about=''
|
||||
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
|
||||
${adobe_createdate(photo.date)}
|
||||
${adobe_modifydate(photo.date)}
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
459
osxphotos/utils.py
Normal file
459
osxphotos/utils.py
Normal file
@@ -0,0 +1,459 @@
|
||||
import glob
|
||||
import logging
|
||||
import os.path
|
||||
import pathlib
|
||||
import platform
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from plistlib import load as plistload
|
||||
|
||||
import CoreFoundation
|
||||
import objc
|
||||
from Foundation import *
|
||||
|
||||
from osxphotos._applescript import AppleScript
|
||||
|
||||
_DEBUG = False
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(levelname)s - %(filename)s - %(lineno)d - %(message)s",
|
||||
)
|
||||
|
||||
if not _DEBUG:
|
||||
logging.disable(logging.DEBUG)
|
||||
|
||||
|
||||
def _get_logger():
|
||||
"""Used only for testing
|
||||
|
||||
Returns:
|
||||
logging.Logger object -- logging.Logger object for osxphotos
|
||||
"""
|
||||
return logging.Logger(__name__)
|
||||
|
||||
|
||||
def _set_debug(debug):
|
||||
""" Enable or disable debug logging """
|
||||
global _DEBUG
|
||||
_DEBUG = debug
|
||||
if debug:
|
||||
logging.disable(logging.NOTSET)
|
||||
else:
|
||||
logging.disable(logging.DEBUG)
|
||||
|
||||
|
||||
def _debug():
|
||||
""" returns True if debugging turned on (via _set_debug), otherwise, false """
|
||||
return _DEBUG
|
||||
|
||||
|
||||
def _get_os_version():
|
||||
# returns tuple containing OS version
|
||||
# e.g. 10.13.6 = (10, 13, 6)
|
||||
version = platform.mac_ver()[0].split(".")
|
||||
if len(version) == 2:
|
||||
(ver, major) = version
|
||||
minor = "0"
|
||||
elif len(version) == 3:
|
||||
(ver, major, minor) = version
|
||||
else:
|
||||
raise (
|
||||
ValueError(
|
||||
f"Could not parse version string: {platform.mac_ver()} {version}"
|
||||
)
|
||||
)
|
||||
return (ver, major, minor)
|
||||
|
||||
|
||||
def _check_file_exists(filename):
|
||||
""" returns true if file exists and is not a directory
|
||||
otherwise returns false """
|
||||
filename = os.path.abspath(filename)
|
||||
return os.path.exists(filename) and not os.path.isdir(filename)
|
||||
|
||||
|
||||
def _get_resource_loc(model_id):
|
||||
""" returns folder_id and file_id needed to find location of edited photo """
|
||||
""" and live photos for version <= Photos 4.0 """
|
||||
# determine folder where Photos stores edited version
|
||||
# edited images are stored in:
|
||||
# Photos Library.photoslibrary/resources/media/version/XX/00/fullsizeoutput_Y.jpeg
|
||||
# where XX and Y are computed based on RKModelResources.modelId
|
||||
|
||||
# file_id (Y in above example) is hex representation of model_id without leading 0x
|
||||
file_id = hex_id = hex(model_id)[2:]
|
||||
|
||||
# folder_id (XX) in above example if first two chars of model_id converted to hex
|
||||
# and left padded with zeros if < 4 digits
|
||||
folder_id = hex_id.zfill(4)[0:2]
|
||||
|
||||
return folder_id, file_id
|
||||
|
||||
|
||||
def _dd_to_dms(dd):
|
||||
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
|
||||
""" return tuple of int(deg), int(min), float(sec) """
|
||||
dd = float(dd)
|
||||
negative = dd < 0
|
||||
dd = abs(dd)
|
||||
min_, sec_ = divmod(dd * 3600, 60)
|
||||
deg_, min_ = divmod(min_, 60)
|
||||
if negative:
|
||||
if deg_ > 0:
|
||||
deg_ = deg_ * -1
|
||||
elif min_ > 0:
|
||||
min_ = min_ * -1
|
||||
else:
|
||||
sec_ = sec_ * -1
|
||||
|
||||
return int(deg_), int(min_), sec_
|
||||
|
||||
|
||||
def _copy_file(src, dest):
|
||||
""" 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 """
|
||||
""" lon: longitude in degrees """
|
||||
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
|
||||
""" this is the same format used by exiftool's json format """
|
||||
# TODO: add this to readme
|
||||
|
||||
lat_deg, lat_min, lat_sec = _dd_to_dms(lat)
|
||||
lon_deg, lon_min, lon_sec = _dd_to_dms(lon)
|
||||
|
||||
lat_hemisphere = "N"
|
||||
if any([lat_deg < 0, lat_min < 0, lat_sec < 0]):
|
||||
lat_hemisphere = "S"
|
||||
|
||||
lon_hemisphere = "E"
|
||||
if any([lon_deg < 0, lon_min < 0, lon_sec < 0]):
|
||||
lon_hemisphere = "W"
|
||||
|
||||
lat_str = (
|
||||
f"{abs(lat_deg)} deg {abs(lat_min)}' {abs(lat_sec):.2f}\" {lat_hemisphere}"
|
||||
)
|
||||
lon_str = (
|
||||
f"{abs(lon_deg)} deg {abs(lon_min)}' {abs(lon_sec):.2f}\" {lon_hemisphere}"
|
||||
)
|
||||
|
||||
return lat_str, lon_str
|
||||
|
||||
|
||||
def get_system_library_path():
|
||||
""" return the path to the system Photos library as string """
|
||||
""" only works on MacOS 10.15+ """
|
||||
""" on earlier versions, returns None """
|
||||
_, major, _ = _get_os_version()
|
||||
if int(major) < 15:
|
||||
logging.debug(
|
||||
f"get_system_library_path not implemented for MacOS < 10.15: you have {major}"
|
||||
)
|
||||
return None
|
||||
|
||||
plist_file = pathlib.Path(
|
||||
str(pathlib.Path.home())
|
||||
+ "/Library/Containers/com.apple.photolibraryd/Data/Library/Preferences/com.apple.photolibraryd.plist"
|
||||
)
|
||||
if plist_file.is_file():
|
||||
with open(plist_file, "rb") as fp:
|
||||
pl = plistload(fp)
|
||||
else:
|
||||
logging.warning(f"could not find plist file: {str(plist_file)}")
|
||||
return None
|
||||
|
||||
photospath = pl["SystemLibraryPath"]
|
||||
|
||||
if photospath is not None:
|
||||
return photospath
|
||||
else:
|
||||
logging.warning("Could not get path to Photos database")
|
||||
return None
|
||||
|
||||
|
||||
def get_last_library_path():
|
||||
""" returns the path to the last opened Photos library
|
||||
If a library has never been opened, returns None """
|
||||
plist_file = pathlib.Path(
|
||||
str(pathlib.Path.home())
|
||||
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
|
||||
)
|
||||
if plist_file.is_file():
|
||||
with open(plist_file, "rb") as fp:
|
||||
pl = plistload(fp)
|
||||
else:
|
||||
logging.debug(f"could not find plist file: {str(plist_file)}")
|
||||
return None
|
||||
|
||||
# get the IPXDefaultLibraryURLBookmark from com.apple.Photos.plist
|
||||
# this is a serialized CFData object
|
||||
photosurlref = pl["IPXDefaultLibraryURLBookmark"]
|
||||
|
||||
if photosurlref is not None:
|
||||
# use CFURLCreateByResolvingBookmarkData to de-serialize bookmark data into a CFURLRef
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=undefined-variable
|
||||
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
|
||||
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
|
||||
)
|
||||
|
||||
# the CFURLRef we got is a sruct that python treats as an array
|
||||
# I'd like to pass this to CFURLGetFileSystemRepresentation to get the path but
|
||||
# CFURLGetFileSystemRepresentation barfs when it gets an array from python instead of expected struct
|
||||
# first element is the path string in form:
|
||||
# file:///Users/username/Pictures/Photos%20Library.photoslibrary/
|
||||
photosurlstr = photosurl[0].absoluteString() if photosurl[0] else None
|
||||
|
||||
# now coerce the file URI back into an OS path
|
||||
# surely there must be a better way
|
||||
if photosurlstr is not None:
|
||||
photospath = os.path.normpath(
|
||||
urllib.parse.unquote(urllib.parse.urlparse(photosurlstr).path)
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
"Could not extract photos URL String from IPXDefaultLibraryURLBookmark"
|
||||
)
|
||||
return None
|
||||
|
||||
return photospath
|
||||
else:
|
||||
logging.debug("Could not get path to Photos database")
|
||||
return None
|
||||
|
||||
|
||||
def list_photo_libraries():
|
||||
""" returns list of Photos libraries found on the system """
|
||||
""" on MacOS < 10.15, this may omit some libraries """
|
||||
|
||||
# On 10.15, mdfind appears to find all libraries
|
||||
# On older MacOS versions, mdfind appears to ignore some libraries
|
||||
# glob to find libraries in ~/Pictures then mdfind to find all the others
|
||||
# TODO: make this more robust
|
||||
lib_list = glob.glob(f"{str(pathlib.Path.home())}/Pictures/*.photoslibrary")
|
||||
|
||||
# On older OS, may not get all libraries so make sure we get the last one
|
||||
last_lib = get_last_library_path()
|
||||
if last_lib:
|
||||
lib_list.append(last_lib)
|
||||
|
||||
output = subprocess.check_output(
|
||||
["/usr/bin/mdfind", "-onlyin", "/", "-name", ".photoslibrary"]
|
||||
).splitlines()
|
||||
for lib in output:
|
||||
lib_list.append(lib.decode("utf-8"))
|
||||
lib_list = list(set(lib_list))
|
||||
lib_list.sort()
|
||||
return lib_list
|
||||
|
||||
|
||||
def create_path_by_date(dest, dt):
|
||||
""" Creates a path in dest folder in form dest/YYYY/MM/DD/
|
||||
dest: valid path as str
|
||||
dt: datetime.timetuple() object
|
||||
Checks to see if path exists, if it does, do nothing and return path
|
||||
If path does not exist, creates it and returns path"""
|
||||
if not os.path.isdir(dest):
|
||||
raise FileNotFoundError(f"dest {dest} must be valid path")
|
||||
yyyy, mm, dd = dt[0:3]
|
||||
yyyy = str(yyyy).zfill(4)
|
||||
mm = str(mm).zfill(2)
|
||||
dd = str(dd).zfill(2)
|
||||
new_dest = os.path.join(dest, yyyy, mm, dd)
|
||||
if not os.path.isdir(new_dest):
|
||||
os.makedirs(new_dest)
|
||||
return new_dest
|
||||
|
||||
|
||||
# TODO: this doesn't always work, still looking for a way to
|
||||
# force Photos to open the library being operated on
|
||||
# def _open_photos_library_applescript(library_path):
|
||||
# """ Force Photos to open a specific library
|
||||
# library_path: path to the Photos library """
|
||||
# open_scpt = AppleScript(
|
||||
# f"""
|
||||
# on openLibrary
|
||||
# tell application "Photos"
|
||||
# open POSIX file "{library_path}"
|
||||
# end tell
|
||||
# end openLibrary
|
||||
# """
|
||||
# )
|
||||
# open_scpt.run()
|
||||
|
||||
|
||||
def _export_photo_uuid_applescript(
|
||||
uuid,
|
||||
dest,
|
||||
filestem=None,
|
||||
original=True,
|
||||
edited=False,
|
||||
live_photo=False,
|
||||
timeout=120,
|
||||
burst=False,
|
||||
):
|
||||
""" Export photo to dest path using applescript to control Photos
|
||||
If photo is a live photo, exports both the photo and associated .mov file
|
||||
uuid: UUID of photo to export
|
||||
dest: destination path to export to
|
||||
filestem: (string) if provided, exported filename will be named stem.ext
|
||||
where ext is extension of the file exported by photos (e.g. .jpeg, .mov, etc)
|
||||
If not provided, file will be named with whatever name Photos uses
|
||||
If filestem.ext exists, it wil be overwritten
|
||||
original: (boolean) if True, export original image; default = True
|
||||
edited: (boolean) if True, export edited photo; default = False
|
||||
If photo not edited and edited=True, will still export the original image
|
||||
caller must verify image has been edited
|
||||
*Note*: must be called with either edited or original but not both,
|
||||
will raise error if called with both edited and original = True
|
||||
live_photo: (boolean) if True, export associated .mov live photo; default = False
|
||||
timeout: timeout value in seconds; export will fail if applescript run time exceeds timeout
|
||||
burst: (boolean) set to True if file is a burst image to avoid Photos export error
|
||||
Returns: list of paths to exported file(s) or None if export failed
|
||||
Note: For Live Photos, if edited=True, will export a jpeg but not the movie, even if photo
|
||||
has not been edited. This is due to how Photos Applescript interface works.
|
||||
"""
|
||||
|
||||
# setup the applescript to do the export
|
||||
export_scpt = AppleScript(
|
||||
"""
|
||||
on export_by_uuid(theUUID, thePath, original, edited, theTimeOut)
|
||||
tell application "Photos"
|
||||
set thePath to thePath
|
||||
set theItem to media item id theUUID
|
||||
set theFilename to filename of theItem
|
||||
set itemList to {theItem}
|
||||
|
||||
if original then
|
||||
with timeout of theTimeOut seconds
|
||||
export itemList to POSIX file thePath with using originals
|
||||
end timeout
|
||||
end if
|
||||
|
||||
if edited then
|
||||
with timeout of theTimeOut seconds
|
||||
export itemList to POSIX file thePath
|
||||
end timeout
|
||||
end if
|
||||
|
||||
return theFilename
|
||||
end tell
|
||||
|
||||
end export_by_uuid
|
||||
"""
|
||||
)
|
||||
|
||||
dest = pathlib.Path(dest)
|
||||
if not dest.is_dir:
|
||||
raise ValueError(f"dest {dest} must be a directory")
|
||||
|
||||
if not original ^ edited:
|
||||
raise ValueError(f"edited or original must be True but not both")
|
||||
|
||||
tmpdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
|
||||
# export original
|
||||
filename = None
|
||||
try:
|
||||
filename = export_scpt.call(
|
||||
"export_by_uuid", uuid, tmpdir.name, original, edited, timeout
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning("Error exporting uuid %s: %s" % (uuid, str(e)))
|
||||
return None
|
||||
|
||||
if filename is not None:
|
||||
# need to find actual filename as sometimes Photos renames JPG to jpeg on export
|
||||
# may be more than one file exported (e.g. if Live Photo, Photos exports both .jpeg and .mov)
|
||||
# TemporaryDirectory will cleanup on return
|
||||
filename_stem = pathlib.Path(filename).stem
|
||||
files = glob.glob(os.path.join(tmpdir.name, "*"))
|
||||
exported_paths = []
|
||||
for fname in files:
|
||||
path = pathlib.Path(fname)
|
||||
if len(files) > 1 and not live_photo and path.suffix.lower() == ".mov":
|
||||
# it's the .mov part of live photo but not requested, so don't export
|
||||
logging.debug(f"Skipping live photo file {path}")
|
||||
continue
|
||||
if len(files) > 1 and burst and path.stem != filename_stem:
|
||||
# skip any burst photo that's not the one we asked for
|
||||
logging.debug(f"Skipping burst photo file {path}")
|
||||
continue
|
||||
if filestem:
|
||||
# rename the file based on filestem, keeping original extension
|
||||
dest_new = dest / f"{filestem}{path.suffix}"
|
||||
else:
|
||||
# use the name Photos provided
|
||||
dest_new = dest / path.name
|
||||
logging.debug(f"exporting {path} to dest_new: {dest_new}")
|
||||
_copy_file(str(path), str(dest_new))
|
||||
exported_paths.append(str(dest_new))
|
||||
return exported_paths
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _open_sql_file(dbname):
|
||||
""" opens sqlite file dbname in read-only mode
|
||||
returns tuple of (connection, cursor) """
|
||||
try:
|
||||
dbpath = pathlib.Path(dbname).resolve()
|
||||
conn = sqlite3.connect(f"{dbpath.as_uri()}?mode=ro", timeout=1, uri=True)
|
||||
c = conn.cursor()
|
||||
except sqlite3.Error as e:
|
||||
sys.exit(f"An error occurred opening sqlite file: {e.args[0]} {dbname}")
|
||||
return (conn, c)
|
||||
|
||||
|
||||
def _db_is_locked(dbname):
|
||||
""" check to see if a sqlite3 db is locked
|
||||
returns True if database is locked, otherwise False
|
||||
dbname: name of database to test """
|
||||
|
||||
# first, check to see if lock file exists, if so, assume the file is locked
|
||||
lock_name = f"{dbname}.lock"
|
||||
if os.path.exists(lock_name):
|
||||
logging.debug(f"{dbname} is locked")
|
||||
return True
|
||||
|
||||
# no lock file so try to read from the database to see if it's locked
|
||||
locked = None
|
||||
try:
|
||||
(conn, c) = _open_sql_file(dbname)
|
||||
c.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;")
|
||||
conn.close()
|
||||
logging.debug(f"{dbname} is not locked")
|
||||
locked = False
|
||||
except:
|
||||
logging.debug(f"{dbname} is locked")
|
||||
locked = True
|
||||
|
||||
return locked
|
||||
234
requirements.txt
234
requirements.txt
@@ -1,122 +1,158 @@
|
||||
altgraph==0.17
|
||||
ansimarkup==1.4.0
|
||||
appdirs==1.4.3
|
||||
astroid==2.2.5
|
||||
atomicwrites==1.3.0
|
||||
attrs==19.1.0
|
||||
better-exceptions-fork==0.2.1.post6
|
||||
# bpylist2==2.0.3;python_version<"3.8"
|
||||
https://github.com/RhetTbull/bpylist/releases/download/v2.0.3/bpylist2-2.0.3.tar.gz#egg=bpylist2;python_version<"3.8"
|
||||
bpylist2==3.0.0;python_version>="3.8"
|
||||
certifi==2019.3.9
|
||||
Click==7.0
|
||||
colorama==0.4.1
|
||||
importlib-metadata==0.18
|
||||
coverage==4.5.4
|
||||
importlib-metadata>=0.18
|
||||
isort==4.3.20
|
||||
lazy-object-proxy==1.4.1
|
||||
loguru==0.2.5
|
||||
macholib==1.14
|
||||
Mako==1.1.1
|
||||
MarkupSafe==1.1.1
|
||||
mccabe==0.6.1
|
||||
modulegraph==0.18
|
||||
more-itertools==7.2.0
|
||||
packaging==19.0
|
||||
pathspec==0.7.0
|
||||
pathvalidate==2.2.1
|
||||
pluggy==0.12.0
|
||||
py==1.8.0
|
||||
py2app==0.21
|
||||
Pygments==2.4.2
|
||||
PyInstaller==3.6
|
||||
pyinstaller-setuptools==2019.3
|
||||
pylint==2.3.1
|
||||
pyobjc==5.2
|
||||
pyobjc-core==5.2
|
||||
pyobjc-framework-Accounts==5.2
|
||||
pyobjc-framework-AddressBook==5.2
|
||||
pyobjc-framework-AdSupport==5.2
|
||||
pyobjc-framework-AppleScriptKit==5.2
|
||||
pyobjc-framework-AppleScriptObjC==5.2
|
||||
pyobjc-framework-ApplicationServices==5.2
|
||||
pyobjc-framework-Automator==5.2
|
||||
pyobjc-framework-AVFoundation==5.2
|
||||
pyobjc-framework-AVKit==5.2
|
||||
pyobjc-framework-BusinessChat==5.2
|
||||
pyobjc-framework-CalendarStore==5.2
|
||||
pyobjc-framework-CFNetwork==5.2
|
||||
pyobjc-framework-CloudKit==5.2
|
||||
pyobjc-framework-Cocoa==5.2
|
||||
pyobjc-framework-Collaboration==5.2
|
||||
pyobjc-framework-ColorSync==5.2
|
||||
pyobjc-framework-Contacts==5.2
|
||||
pyobjc-framework-ContactsUI==5.2
|
||||
pyobjc-framework-CoreAudio==5.2
|
||||
pyobjc-framework-CoreAudioKit==5.2
|
||||
pyobjc-framework-CoreBluetooth==5.2
|
||||
pyobjc-framework-CoreData==5.2
|
||||
pyobjc-framework-CoreLocation==5.2
|
||||
pyobjc-framework-CoreMedia==5.2
|
||||
pyobjc-framework-CoreMediaIO==5.2
|
||||
pyobjc-framework-CoreML==5.2
|
||||
pyobjc-framework-CoreServices==5.2
|
||||
pyobjc-framework-CoreSpotlight==5.2
|
||||
pyobjc-framework-CoreText==5.2
|
||||
pyobjc-framework-CoreWLAN==5.2
|
||||
pyobjc-framework-CryptoTokenKit==5.2
|
||||
pyobjc-framework-DictionaryServices==5.2
|
||||
pyobjc-framework-DiscRecording==5.2
|
||||
pyobjc-framework-DiscRecordingUI==5.2
|
||||
pyobjc-framework-DiskArbitration==5.2
|
||||
pyobjc-framework-DVDPlayback==5.2
|
||||
pyobjc-framework-EventKit==5.2
|
||||
pyobjc-framework-ExceptionHandling==5.2
|
||||
pyobjc-framework-ExternalAccessory==5.2
|
||||
pyobjc-framework-FinderSync==5.2
|
||||
pyobjc-framework-FSEvents==5.2
|
||||
pyobjc-framework-GameCenter==5.2
|
||||
pyobjc-framework-GameController==5.2
|
||||
pyobjc-framework-GameKit==5.2
|
||||
pyobjc-framework-GameplayKit==5.2
|
||||
pyobjc-framework-ImageCaptureCore==5.2
|
||||
pyobjc-framework-IMServicePlugIn==5.2
|
||||
pyobjc-framework-InputMethodKit==5.2
|
||||
pyobjc-framework-InstallerPlugins==5.2
|
||||
pyobjc-framework-InstantMessage==5.2
|
||||
pyobjc-framework-Intents==5.2
|
||||
pyobjc-framework-IOSurface==5.2
|
||||
pyobjc-framework-iTunesLibrary==5.2
|
||||
pyobjc-framework-LatentSemanticMapping==5.2
|
||||
pyobjc-framework-LaunchServices==5.2
|
||||
pyobjc-framework-libdispatch==5.2
|
||||
pyobjc-framework-LocalAuthentication==5.2
|
||||
pyobjc-framework-MapKit==5.2
|
||||
pyobjc-framework-MediaAccessibility==5.2
|
||||
pyobjc-framework-MediaLibrary==5.2
|
||||
pyobjc-framework-MediaPlayer==5.2
|
||||
pyobjc-framework-MediaToolbox==5.2
|
||||
pyobjc-framework-ModelIO==5.2
|
||||
pyobjc-framework-MultipeerConnectivity==5.2
|
||||
pyobjc-framework-NaturalLanguage==5.2
|
||||
pyobjc-framework-NetFS==5.2
|
||||
pyobjc-framework-Network==5.2
|
||||
pyobjc-framework-NetworkExtension==5.2
|
||||
pyobjc-framework-NotificationCenter==5.2
|
||||
pyobjc-framework-OpenDirectory==5.2
|
||||
pyobjc-framework-OSAKit==5.2
|
||||
pyobjc-framework-Photos==5.2
|
||||
pyobjc-framework-PhotosUI==5.2
|
||||
pyobjc-framework-PreferencePanes==5.2
|
||||
pyobjc-framework-PubSub==5.2
|
||||
pyobjc-framework-QTKit==5.2
|
||||
pyobjc-framework-Quartz==5.2
|
||||
pyobjc-framework-SafariServices==5.2
|
||||
pyobjc-framework-SceneKit==5.2
|
||||
pyobjc-framework-ScreenSaver==5.2
|
||||
pyobjc-framework-ScriptingBridge==5.2
|
||||
pyobjc-framework-SearchKit==5.2
|
||||
pyobjc-framework-Security==5.2
|
||||
pyobjc-framework-SecurityFoundation==5.2
|
||||
pyobjc-framework-SecurityInterface==5.2
|
||||
pyobjc-framework-ServiceManagement==5.2
|
||||
pyobjc-framework-Social==5.2
|
||||
pyobjc-framework-SpriteKit==5.2
|
||||
pyobjc-framework-StoreKit==5.2
|
||||
pyobjc-framework-SyncServices==5.2
|
||||
pyobjc-framework-SystemConfiguration==5.2
|
||||
pyobjc-framework-UserNotifications==5.2
|
||||
pyobjc-framework-VideoSubscriberAccount==5.2
|
||||
pyobjc-framework-VideoToolbox==5.2
|
||||
pyobjc-framework-Vision==5.2
|
||||
pyobjc-framework-WebKit==5.2
|
||||
pyobjc==6.0.1
|
||||
pyobjc-core==6.0.1
|
||||
pyobjc-framework-Accounts==6.0.1
|
||||
pyobjc-framework-AddressBook==6.0.1
|
||||
pyobjc-framework-AdSupport==6.0.1
|
||||
pyobjc-framework-AppleScriptKit==6.0.1
|
||||
pyobjc-framework-AppleScriptObjC==6.0.1
|
||||
pyobjc-framework-ApplicationServices==6.0.1
|
||||
pyobjc-framework-AuthenticationServices==6.0.1
|
||||
pyobjc-framework-Automator==6.0.1
|
||||
pyobjc-framework-AVFoundation==6.0.1
|
||||
pyobjc-framework-AVKit==6.0.1
|
||||
pyobjc-framework-BusinessChat==6.0.1
|
||||
pyobjc-framework-CalendarStore==6.0.1
|
||||
pyobjc-framework-CFNetwork==6.0.1
|
||||
pyobjc-framework-CloudKit==6.0.1
|
||||
pyobjc-framework-Cocoa==6.0.1
|
||||
pyobjc-framework-Collaboration==6.0.1
|
||||
pyobjc-framework-ColorSync==6.0.1
|
||||
pyobjc-framework-Contacts==6.0.1
|
||||
pyobjc-framework-ContactsUI==6.0.1
|
||||
pyobjc-framework-CoreAudio==6.0.1
|
||||
pyobjc-framework-CoreAudioKit==6.0.1
|
||||
pyobjc-framework-CoreBluetooth==6.0.1
|
||||
pyobjc-framework-CoreData==6.0.1
|
||||
pyobjc-framework-CoreHaptics==6.0.1
|
||||
pyobjc-framework-CoreLocation==6.0.1
|
||||
pyobjc-framework-CoreMedia==6.0.1
|
||||
pyobjc-framework-CoreMediaIO==6.0.1
|
||||
pyobjc-framework-CoreML==6.0.1
|
||||
pyobjc-framework-CoreMotion==6.0.1
|
||||
pyobjc-framework-CoreServices==6.0.1
|
||||
pyobjc-framework-CoreSpotlight==6.0.1
|
||||
pyobjc-framework-CoreText==6.0.1
|
||||
pyobjc-framework-CoreWLAN==6.0.1
|
||||
pyobjc-framework-CryptoTokenKit==6.0.1
|
||||
pyobjc-framework-DeviceCheck==6.0.1
|
||||
pyobjc-framework-DictionaryServices==6.0.1
|
||||
pyobjc-framework-DiscRecording==6.0.1
|
||||
pyobjc-framework-DiscRecordingUI==6.0.1
|
||||
pyobjc-framework-DiskArbitration==6.0.1
|
||||
pyobjc-framework-DVDPlayback==6.0.1
|
||||
pyobjc-framework-EventKit==6.0.1
|
||||
pyobjc-framework-ExceptionHandling==6.0.1
|
||||
pyobjc-framework-ExecutionPolicy==6.0.1
|
||||
pyobjc-framework-ExternalAccessory==6.0.1
|
||||
pyobjc-framework-FileProvider==6.0.1
|
||||
pyobjc-framework-FileProviderUI==6.0.1
|
||||
pyobjc-framework-FinderSync==6.0.1
|
||||
pyobjc-framework-FSEvents==6.0.1
|
||||
pyobjc-framework-GameCenter==6.0.1
|
||||
pyobjc-framework-GameController==6.0.1
|
||||
pyobjc-framework-GameKit==6.0.1
|
||||
pyobjc-framework-GameplayKit==6.0.1
|
||||
pyobjc-framework-ImageCaptureCore==6.0.1
|
||||
pyobjc-framework-IMServicePlugIn==6.0.1
|
||||
pyobjc-framework-InputMethodKit==6.0.1
|
||||
pyobjc-framework-InstallerPlugins==6.0.1
|
||||
pyobjc-framework-InstantMessage==6.0.1
|
||||
pyobjc-framework-Intents==6.0.1
|
||||
pyobjc-framework-IOSurface==6.0.1
|
||||
pyobjc-framework-iTunesLibrary==6.0.1
|
||||
pyobjc-framework-LatentSemanticMapping==6.0.1
|
||||
pyobjc-framework-LaunchServices==6.0.1
|
||||
pyobjc-framework-libdispatch==6.0.1
|
||||
pyobjc-framework-LinkPresentation==6.0.1
|
||||
pyobjc-framework-LocalAuthentication==6.0.1
|
||||
pyobjc-framework-MapKit==6.0.1
|
||||
pyobjc-framework-MediaAccessibility==6.0.1
|
||||
pyobjc-framework-MediaLibrary==6.0.1
|
||||
pyobjc-framework-MediaPlayer==6.0.1
|
||||
pyobjc-framework-MediaToolbox==6.0.1
|
||||
pyobjc-framework-MetalKit==6.0.1
|
||||
pyobjc-framework-ModelIO==6.0.1
|
||||
pyobjc-framework-MultipeerConnectivity==6.0.1
|
||||
pyobjc-framework-NaturalLanguage==6.0.1
|
||||
pyobjc-framework-NetFS==6.0.1
|
||||
pyobjc-framework-Network==6.0.1
|
||||
pyobjc-framework-NetworkExtension==6.0.1
|
||||
pyobjc-framework-NotificationCenter==6.0.1
|
||||
pyobjc-framework-OpenDirectory==6.0.1
|
||||
pyobjc-framework-OSAKit==6.0.1
|
||||
pyobjc-framework-OSLog==6.0.1
|
||||
pyobjc-framework-PencilKit==6.0.1
|
||||
pyobjc-framework-Photos==6.0.1
|
||||
pyobjc-framework-PhotosUI==6.0.1
|
||||
pyobjc-framework-PreferencePanes==6.0.1
|
||||
pyobjc-framework-PubSub==6.0.1
|
||||
pyobjc-framework-PushKit==6.0.1
|
||||
pyobjc-framework-QTKit==6.0.1
|
||||
pyobjc-framework-Quartz==6.0.1
|
||||
pyobjc-framework-QuickLookThumbnailing==6.0.1
|
||||
pyobjc-framework-SafariServices==6.0.1
|
||||
pyobjc-framework-SceneKit==6.0.1
|
||||
pyobjc-framework-ScreenSaver==6.0.1
|
||||
pyobjc-framework-ScriptingBridge==6.0.1
|
||||
pyobjc-framework-SearchKit==6.0.1
|
||||
pyobjc-framework-Security==6.0.1
|
||||
pyobjc-framework-SecurityFoundation==6.0.1
|
||||
pyobjc-framework-SecurityInterface==6.0.1
|
||||
pyobjc-framework-ServiceManagement==6.0.1
|
||||
pyobjc-framework-Social==6.0.1
|
||||
pyobjc-framework-SoundAnalysis==6.0.1
|
||||
pyobjc-framework-Speech==6.0.1
|
||||
pyobjc-framework-SpriteKit==6.0.1
|
||||
pyobjc-framework-StoreKit==6.0.1
|
||||
pyobjc-framework-SyncServices==6.0.1
|
||||
pyobjc-framework-SystemConfiguration==6.0.1
|
||||
pyobjc-framework-SystemExtensions==6.0.1
|
||||
pyobjc-framework-UserNotifications==6.0.1
|
||||
pyobjc-framework-VideoSubscriberAccount==6.0.1
|
||||
pyobjc-framework-VideoToolbox==6.0.1
|
||||
pyobjc-framework-Vision==6.0.1
|
||||
pyobjc-framework-WebKit==6.0.1
|
||||
pyparsing==2.4.1.1
|
||||
PyYAML==5.1.2
|
||||
regex==2020.2.20
|
||||
six==1.12.0
|
||||
termcolor==1.1.0
|
||||
toml==0.10.0
|
||||
typed-ast==1.4.1
|
||||
wcwidth==0.1.7
|
||||
wrapt==1.11.1
|
||||
zipp==0.5.2
|
||||
|
||||
38
setup.py
38
setup.py
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# setup.py script for osxphotos
|
||||
# setup.py script for osxphotos
|
||||
#
|
||||
# Copyright (c) 2019 Rhet Turnbull, rturnbull+git@gmail.com
|
||||
# All rights reserved.
|
||||
@@ -26,19 +26,22 @@
|
||||
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
# from distutils.core import setup
|
||||
from setuptools import setup, find_packages
|
||||
import os
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
# read the contents of README file
|
||||
from os import path
|
||||
|
||||
this_directory = path.abspath(path.dirname(__file__))
|
||||
with open(path.join(this_directory, "README.md"), encoding="utf-8") as f:
|
||||
this_directory = os.path.abspath(os.path.dirname(__file__))
|
||||
with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
|
||||
long_description = f.read()
|
||||
|
||||
about = {}
|
||||
with open(
|
||||
os.path.join(this_directory, "osxphotos", "_version.py"), mode="r", encoding="utf-8"
|
||||
) as f:
|
||||
exec(f.read(), about)
|
||||
|
||||
setup(
|
||||
name="osxphotos",
|
||||
version="0.12.3",
|
||||
version=about["__version__"],
|
||||
description="Manipulate (read-only) Apple's Photos app library on Mac OS X",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
@@ -47,7 +50,7 @@ setup(
|
||||
url="https://github.com/RhetTbull/",
|
||||
project_urls={"GitHub": "https://github.com/RhetTbull/osxphotos"},
|
||||
download_url="https://github.com/RhetTbull/osxphotos",
|
||||
packages=find_packages(exclude=["tests","examples"]),
|
||||
packages=find_packages(exclude=["tests", "examples", "utils"]),
|
||||
license="License :: OSI Approved :: MIT License",
|
||||
classifiers=[
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -58,8 +61,15 @@ setup(
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
],
|
||||
install_requires=["pyobjc","Click","pyyaml",],
|
||||
entry_points = {
|
||||
'console_scripts' : ['osxphotos=osxphotos.cmd_line:cli'],
|
||||
}
|
||||
install_requires=[
|
||||
"pyobjc>=6.0.1",
|
||||
"Click>=7",
|
||||
"PyYAML>=5.1.2",
|
||||
"Mako>=1.1.1",
|
||||
"bpylist2==2.0.3;python_version<'3.8'",
|
||||
"bpylist2==3.0.0;python_version>='3.8'",
|
||||
"pathvalidate==2.2.1",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>DatabaseMinorVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>DatabaseVersion</key>
|
||||
<integer>112</integer>
|
||||
<key>LastOpenMode</key>
|
||||
<integer>2</integer>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>4025</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>2</integer>
|
||||
<key>createDate</key>
|
||||
<date>2019-12-27T23:19:08Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Photos</key>
|
||||
<dict>
|
||||
<key>CollapsedSidebarSectionIdentifiers</key>
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>TopLevelAlbums</string>
|
||||
<string>TopLevelSlideshows</string>
|
||||
</array>
|
||||
<key>lastKnownItemCounts</key>
|
||||
<dict>
|
||||
<key>other</key>
|
||||
<integer>0</integer>
|
||||
<key>photos</key>
|
||||
<integer>0</integer>
|
||||
<key>videos</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2019-12-27T23:19:59Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PLLanguageAndLocaleKey</key>
|
||||
<string>en-US:en_US</string>
|
||||
<key>PLLastGeoProviderIdKey</key>
|
||||
<string>7618</string>
|
||||
<key>PLLastLocationInfoFormatVer</key>
|
||||
<integer>12</integer>
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>53</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>F176BAF5-4B7A-4878-83C4-4D4175F299BF</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>4025</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>DatabaseMinorVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>DatabaseVersion</key>
|
||||
<integer>112</integer>
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>53</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>F176BAF5-4B7A-4878-83C4-4D4175F299BF</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>4025</integer>
|
||||
</dict>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>4025</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>2</integer>
|
||||
<key>SnapshotComplete</key>
|
||||
<true/>
|
||||
<key>SnapshotCompletedDate</key>
|
||||
<date>2019-12-27T23:19:08Z</date>
|
||||
<key>SnapshotLastValidated</key>
|
||||
<date>2019-12-27T23:19:08Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,5 +17,6 @@ Images used from:
|
||||
- [Carlos Montesdeoca](https://www.flickr.com/photos/carlosmontesdeocastudio)
|
||||
- [Rydale Clothing](https://www.flickr.com/photos/rydaleclothing)
|
||||
- [Marco Verch](https://www.flickr.com/photos/30478819@N08/48228222317/)
|
||||
- [K M](https://www.flickr.com/photos/153387643@N08/49334338022/)
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
BIN
tests/Test-10.12.6.photoslibrary/database/photos.db-shm
Normal file
BIN
tests/Test-10.12.6.photoslibrary/database/photos.db-shm
Normal file
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
<key>LithiumMessageTracer</key>
|
||||
<dict>
|
||||
<key>LastReportedDate</key>
|
||||
<date>2019-08-24T02:50:48Z</date>
|
||||
<date>2019-12-08T16:44:38Z</date>
|
||||
</dict>
|
||||
<key>PXPeopleScreenUnlocked</key>
|
||||
<true/>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2019-08-24T02:51:33Z</date>
|
||||
<date>2020-01-22T02:10:26Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2019-08-24T13:19:30Z</date>
|
||||
<date>2020-01-22T02:10:27Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2019-08-24T02:51:30Z</date>
|
||||
<date>2020-01-19T17:29:28Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>403</integer>
|
||||
<integer>414</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
Binary file not shown.
BIN
tests/Test-10.13.6.photoslibrary/database/photos.db-shm
Normal file
BIN
tests/Test-10.13.6.photoslibrary/database/photos.db-shm
Normal file
Binary file not shown.
BIN
tests/Test-10.14.5.photoslibrary/database/photos.db-shm
Normal file
BIN
tests/Test-10.14.5.photoslibrary/database/photos.db-shm
Normal file
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 545 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 532 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 578 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 504 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>DatabaseMinorVersion</key>
|
||||
<integer>1</integer>
|
||||
<key>DatabaseVersion</key>
|
||||
<integer>112</integer>
|
||||
<key>LastOpenMode</key>
|
||||
<integer>2</integer>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>4025</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>2</integer>
|
||||
<key>createDate</key>
|
||||
<date>2019-07-27T13:16:43Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/Test-10.14.6-path_edited.photoslibrary/database/photos.db
Normal file
BIN
tests/Test-10.14.6-path_edited.photoslibrary/database/photos.db
Normal file
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Photos</key>
|
||||
<dict>
|
||||
<key>CollapsedSidebarSectionIdentifiers</key>
|
||||
<array/>
|
||||
<key>ExpandedSidebarItemIdentifiers</key>
|
||||
<array>
|
||||
<string>TopLevelAlbums</string>
|
||||
<string>TopLevelSlideshows</string>
|
||||
</array>
|
||||
<key>IPXWorkspaceControllerZoomLevelsKey</key>
|
||||
<dict>
|
||||
<key>kZoomLevelIdentifierAlbums</key>
|
||||
<integer>10</integer>
|
||||
<key>kZoomLevelIdentifierVersions</key>
|
||||
<integer>7</integer>
|
||||
</dict>
|
||||
<key>lastAddToDestination</key>
|
||||
<dict>
|
||||
<key>key</key>
|
||||
<integer>1</integer>
|
||||
<key>lastKnownDisplayName</key>
|
||||
<string>Test Album (1)</string>
|
||||
<key>type</key>
|
||||
<string>album</string>
|
||||
<key>uuid</key>
|
||||
<string>Uq6qsKihRRSjMHTiD+0Azg</string>
|
||||
</dict>
|
||||
<key>lastKnownItemCounts</key>
|
||||
<dict>
|
||||
<key>other</key>
|
||||
<integer>0</integer>
|
||||
<key>photos</key>
|
||||
<integer>6</integer>
|
||||
<key>videos</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-01-11T16:41:00Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-01-12T06:02:45Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user