Compare commits
259 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
478715a363 | ||
|
|
74f1002b9a | ||
|
|
2f57abd23c | ||
|
|
f9a43b92c1 | ||
|
|
bf2a55d7f6 | ||
|
|
34bb7f2cdc | ||
|
|
3394c52768 | ||
|
|
27282af3b9 | ||
|
|
b7b06b9fdb | ||
|
|
29e424575a | ||
|
|
ea373c4197 | ||
|
|
f25a299309 | ||
|
|
5885b23d32 | ||
|
|
5dccdf7750 | ||
|
|
e9134f84df | ||
|
|
3872e7ae64 | ||
|
|
b3e86dffc8 | ||
|
|
4897fc4b05 | ||
|
|
1dbf22fdc9 | ||
|
|
fa58af8b88 | ||
|
|
9c9bcb08b3 | ||
|
|
b1cb99f83f | ||
|
|
d3605f6303 | ||
|
|
dce002cdfe | ||
|
|
7bd189e9b2 | ||
|
|
baa86c77f6 | ||
|
|
0d086bf851 | ||
|
|
ade98fc150 | ||
|
|
0d66759b1c | ||
|
|
d833c14ef4 | ||
|
|
34841f86c0 | ||
|
|
4cc40d24cf | ||
|
|
1ccf03e158 | ||
|
|
75888cd663 | ||
|
|
a08d0725b9 | ||
|
|
f9f699ba35 | ||
|
|
f469cccc4b | ||
|
|
4ece5c0d1c | ||
|
|
9ca5d8f0fd | ||
|
|
2a49255277 | ||
|
|
f3b7134af1 | ||
|
|
73716f12cd | ||
|
|
a4bbb6492d | ||
|
|
aca19f4063 | ||
|
|
2ebd4c33ff | ||
|
|
da2f91ffc7 | ||
|
|
ef94933dd8 | ||
|
|
e0e8850e56 | ||
|
|
8d1ccda0c8 | ||
|
|
6171c4d665 | ||
|
|
4678f15bc8 | ||
|
|
a7c688cfc2 | ||
|
|
880a9b67a1 | ||
|
|
d40b16a456 | ||
|
|
dcd2fde6d0 | ||
|
|
ad860b1500 | ||
|
|
7ad4db6c15 | ||
|
|
0f1cc7cc71 | ||
|
|
5e6a6cd5fb | ||
|
|
e394d8e6be | ||
|
|
8237bc8267 | ||
|
|
e097f3aad5 | ||
|
|
3155045ec8 | ||
|
|
4f64eeb996 | ||
|
|
3c14ace826 | ||
|
|
d5730dd8ae | ||
|
|
5c1c0c5c5a | ||
|
|
d8593a01e2 | ||
|
|
1dffe894ff | ||
|
|
29721dd4f0 | ||
|
|
6559c4d8f6 | ||
|
|
baf45ccd2a | ||
|
|
aca85ee2aa | ||
|
|
9584a9ccc5 | ||
|
|
182b816e34 | ||
|
|
0262e0d97e | ||
|
|
73f936e061 | ||
|
|
09687cfca4 | ||
|
|
e17ee0e388 | ||
|
|
ec4b53ed9d | ||
|
|
d7c81adae8 | ||
|
|
37b1e5ca47 | ||
|
|
22355fd446 | ||
|
|
d8de86cb6f | ||
|
|
11f563a479 | ||
|
|
f75ed17f9c | ||
|
|
e5d6f21d8e | ||
|
|
d371e63022 | ||
|
|
1b6a03a9f8 | ||
|
|
0708a42155 | ||
|
|
69cd236712 | ||
|
|
4cce9d4939 | ||
|
|
cfb07cbfaf | ||
|
|
1eff6bae9e | ||
|
|
435da2a5dd | ||
|
|
ed3a9711dc | ||
|
|
1bc0926948 | ||
|
|
25eacc7cad | ||
|
|
d9dcf0917a | ||
|
|
4f36c7c948 | ||
|
|
d22eaf39ed | ||
|
|
adf2ba7678 | ||
|
|
af827d7a57 | ||
|
|
48acb42631 | ||
|
|
eba661acf7 | ||
|
|
399d432a66 | ||
|
|
4cebc57d60 | ||
|
|
489fea56e9 | ||
|
|
0632a97f55 | ||
|
|
d5a9f76719 | ||
|
|
382fca3f92 | ||
|
|
a807894095 | ||
|
|
559350f71d | ||
|
|
b5195f9d2b | ||
|
|
fa332186ab | ||
|
|
aa2ebf55bb | ||
|
|
d1fbb9fe86 | ||
|
|
116cb662fb | ||
|
|
db68defc44 | ||
|
|
7460bc88fc | ||
|
|
dbbbbf10a8 | ||
|
|
0633814ab2 | ||
|
|
df7d45659a | ||
|
|
cec266bba4 | ||
|
|
d0d2e80800 | ||
|
|
aafdbea564 | ||
|
|
c42050a10c | ||
|
|
c27cfb1223 | ||
|
|
ad144da8a0 | ||
|
|
5352aec3b9 | ||
|
|
e951e5361e | ||
|
|
f7bd1376e1 | ||
|
|
26f96d582c | ||
|
|
8cb15d1555 | ||
|
|
2d9429c8ee | ||
|
|
3b6dd08d2b | ||
|
|
3c85f26f90 | ||
|
|
52c054f81f | ||
|
|
8dc59cbc35 | ||
|
|
802e2f069a | ||
|
|
5d4d7d7db7 | ||
|
|
ea9b41bae4 | ||
|
|
38397b507b | ||
|
|
3636fcbc76 | ||
|
|
a6231e29ff | ||
|
|
8c36c6712a | ||
|
|
7fa3704840 | ||
|
|
e829212987 | ||
|
|
df37a017a8 | ||
|
|
101525c95f | ||
|
|
ae2fd2e3db | ||
|
|
9588853ea2 | ||
|
|
9d38885416 | ||
|
|
653b7e6600 | ||
|
|
9429ea8ace | ||
|
|
2202f1b1e9 | ||
|
|
a509ef18d3 | ||
|
|
0492f94060 | ||
|
|
cf7fab4c7a | ||
|
|
c7c5320587 | ||
|
|
cd710771cd | ||
|
|
663e33bc17 | ||
|
|
3660b6360a | ||
|
|
11459d1da4 | ||
|
|
fd14242022 | ||
|
|
6ac311199e | ||
|
|
13df6a2395 | ||
|
|
28dce72a67 | ||
|
|
e5548ed160 | ||
|
|
5714509765 | ||
|
|
46b62af4e2 | ||
|
|
01ea88fe57 | ||
|
|
e6d043ab65 | ||
|
|
5b1174db5d | ||
|
|
9cff8e89c6 | ||
|
|
1553563629 | ||
|
|
db262f58b0 | ||
|
|
0cce234a8c | ||
|
|
c5dba8c89b | ||
|
|
603dabb8f4 | ||
|
|
091f1d9bb4 | ||
|
|
d16932d0fd | ||
|
|
23de6b5890 | ||
|
|
4fe58bf2af | ||
|
|
d87b8f30a4 | ||
|
|
667c89e32c | ||
|
|
f9cac05f0d | ||
|
|
48f29e138e | ||
|
|
7f2701f6ee | ||
|
|
8551981f68 | ||
|
|
a416de29e4 | ||
|
|
a960468887 | ||
|
|
ea68229dda | ||
|
|
a95193aaa4 | ||
|
|
71ef5e5195 | ||
|
|
53b2498e59 | ||
|
|
15e0914af6 | ||
|
|
3b3eb1625e | ||
|
|
338b1501d0 | ||
|
|
bda3a029de | ||
|
|
ff0fdffa9b | ||
|
|
1332e7b45a | ||
|
|
41b23991df | ||
|
|
da100f93a9 | ||
|
|
d049967c6b | ||
|
|
dcbf8f25f6 | ||
|
|
0d6b68d7ba | ||
|
|
07b08433df | ||
|
|
b0171ba6f5 | ||
|
|
16305cf233 | ||
|
|
fe5185be88 | ||
|
|
58362020cb | ||
|
|
464eae2b98 | ||
|
|
b5a9794f6b | ||
|
|
b32f4b8504 | ||
|
|
0dd05b8cc1 | ||
|
|
9515736019 | ||
|
|
42a6373f8d | ||
|
|
6413342bdb | ||
|
|
5f14349964 | ||
|
|
b2b39aa607 | ||
|
|
0ddd5234b2 | ||
|
|
ae0166da04 | ||
|
|
c389207daa | ||
|
|
25141e4945 | ||
|
|
1b181094ed | ||
|
|
d406d30414 | ||
|
|
9324d8e795 | ||
|
|
4099253c8e | ||
|
|
2e652b04d0 | ||
|
|
5a13605f85 | ||
|
|
15eb940ff0 | ||
|
|
22ecf8279a | ||
|
|
38f201d0fb | ||
|
|
08725fd27f | ||
|
|
62d54cc0be | ||
|
|
6883fec2b2 | ||
|
|
228dfcdc67 | ||
|
|
c939df7171 | ||
|
|
3d21dadf41 | ||
|
|
ddc1e69b4a | ||
|
|
432da7f139 | ||
|
|
aa2cf826c7 | ||
|
|
459d91d7b1 | ||
|
|
eb00ffd737 | ||
|
|
a1776fa148 | ||
|
|
f1d20103ff | ||
|
|
5f2d401048 | ||
|
|
58b3869a7c | ||
|
|
c2fecc9d30 | ||
|
|
1f343c1c11 | ||
|
|
a36eb416b1 | ||
|
|
c9b15186a0 | ||
|
|
315fe6a6a3 | ||
|
|
b611d34d19 | ||
|
|
001e474d56 | ||
|
|
60d96a8f56 | ||
|
|
42e8fba125 | ||
|
|
a91617cce4 |
124
.all-contributorsrc
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"projectName": "osxphotos",
|
||||
"projectOwner": "RhetTbull",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": true,
|
||||
"commitConvention": "none",
|
||||
"contributors": [
|
||||
{
|
||||
"login": "britiscurious",
|
||||
"name": "britiscurious",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/25646439?v=4",
|
||||
"profile": "https://github.com/britiscurious",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mwort",
|
||||
"name": "Michel Wortmann",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/8170417?v=4",
|
||||
"profile": "https://github.com/mwort",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "PabloKohan",
|
||||
"name": "Pablo 'merKur' Kohan",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/8790976?v=4",
|
||||
"profile": "https://github.com/PabloKohan",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hshore29",
|
||||
"name": "hshore29",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/7023497?v=4",
|
||||
"profile": "https://github.com/hshore29",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dmd",
|
||||
"name": "Daniel M. Drucker",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/41439?v=4",
|
||||
"profile": "http://3e.org/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jystervinou",
|
||||
"name": "Jean-Yves Stervinou",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/132356?v=4",
|
||||
"profile": "https://github.com/jystervinou",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dethi",
|
||||
"name": "Thibault Deutsch",
|
||||
"avatar_url": "https://avatars2.githubusercontent.com/u/1011520?v=4",
|
||||
"profile": "https://dethi.me/",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "grundsch",
|
||||
"name": "grundsch",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/3874928?v=4",
|
||||
"profile": "https://github.com/grundsch",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "agprimatic",
|
||||
"name": "Ag Primatic",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/4685054?v=4",
|
||||
"profile": "https://github.com/agprimatic",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hhoeck",
|
||||
"name": "Horst Höck",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/6313998?v=4",
|
||||
"profile": "https://github.com/hhoeck",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jstrine",
|
||||
"name": "Jonathan Strine",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/33943447?v=4",
|
||||
"profile": "https://github.com/jstrine",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "finestream",
|
||||
"name": "finestream",
|
||||
"avatar_url": "https://avatars1.githubusercontent.com/u/16638513?v=4",
|
||||
"profile": "https://github.com/finestream",
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7
|
||||
}
|
||||
535
CHANGELOG.md
@@ -4,6 +4,541 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v0.39.3](https://github.com/RhetTbull/osxphotos/compare/v0.39.2...v0.39.3)
|
||||
|
||||
> 31 December 2020
|
||||
|
||||
- Fixed modified template to use creation time if no modificationd date, issue #312 [`2f57abd`](https://github.com/RhetTbull/osxphotos/commit/2f57abd23cabe57bcf667a1713c37689b330a702)
|
||||
|
||||
#### [v0.39.2](https://github.com/RhetTbull/osxphotos/compare/v0.39.1...v0.39.2)
|
||||
|
||||
> 31 December 2020
|
||||
|
||||
- Added --xattr-template, closes #242 [`#242`](https://github.com/RhetTbull/osxphotos/issues/242)
|
||||
|
||||
#### [v0.39.1](https://github.com/RhetTbull/osxphotos/compare/v0.39.0...v0.39.1)
|
||||
|
||||
> 31 December 2020
|
||||
|
||||
- Fixed --exiftool-path bug, issue #311, #313 [`3394c52`](https://github.com/RhetTbull/osxphotos/commit/3394c527682d8fdd2f20f4f778d802dab86b6372)
|
||||
|
||||
#### [v0.39.0](https://github.com/RhetTbull/osxphotos/compare/v0.38.22...v0.39.0)
|
||||
|
||||
> 30 December 2020
|
||||
|
||||
- Added Finder tags, partial implementation for issue #242 [`#310`](https://github.com/RhetTbull/osxphotos/pull/310)
|
||||
- Added tests for Finder tags [`29e4245`](https://github.com/RhetTbull/osxphotos/commit/29e424575a522ae03efe5a140be46bfd0a1346c5)
|
||||
- Initial implementation for Finder tags [`5885b23`](https://github.com/RhetTbull/osxphotos/commit/5885b23d3249cf91953092a6b1ce967da2667e29)
|
||||
- Updated README for finder tags [`f25a299`](https://github.com/RhetTbull/osxphotos/commit/f25a2993097ad7b2b8ab2d1c787db58c0d799a41)
|
||||
- Updated requirements.txt [`ea373c4`](https://github.com/RhetTbull/osxphotos/commit/ea373c4197ce1cce00e89157fe560d1366f7e764)
|
||||
|
||||
#### [v0.38.22](https://github.com/RhetTbull/osxphotos/compare/v0.38.21...v0.38.22)
|
||||
|
||||
> 30 December 2020
|
||||
|
||||
- Fixed --exiftool-path bug, issue #308 [`5dccdf7`](https://github.com/RhetTbull/osxphotos/commit/5dccdf7750611c78de5356bb02f6023d4fc382c5)
|
||||
|
||||
#### [v0.38.21](https://github.com/RhetTbull/osxphotos/compare/v0.38.20...v0.38.21)
|
||||
|
||||
> 29 December 2020
|
||||
|
||||
- Fixed --exiftool-path to work with --exiftool-merge-keywords/persons [`3872e7a`](https://github.com/RhetTbull/osxphotos/commit/3872e7ae649f42d849de472a7dbf78a241d54407)
|
||||
|
||||
#### [v0.38.20](https://github.com/RhetTbull/osxphotos/compare/v0.38.19...v0.38.20)
|
||||
|
||||
> 29 December 2020
|
||||
|
||||
- Added --exiftool-path to CLI [`4897fc4`](https://github.com/RhetTbull/osxphotos/commit/4897fc4b05cc7a3bea314f9cce8a2163bf3922b2)
|
||||
|
||||
#### [v0.38.19](https://github.com/RhetTbull/osxphotos/compare/v0.38.18...v0.38.19)
|
||||
|
||||
> 29 December 2020
|
||||
|
||||
- Added exiftool signature to JSON output, issue #303 [`fa58af8`](https://github.com/RhetTbull/osxphotos/commit/fa58af8b883da11fdfa723d2da75a600d927d46e)
|
||||
|
||||
#### [v0.38.18](https://github.com/RhetTbull/osxphotos/compare/v0.38.17...v0.38.18)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
- Added --exiftool-merge-keywords/persons, issue #299, #292 [`b1cb99f`](https://github.com/RhetTbull/osxphotos/commit/b1cb99f83f55128a314d265d4588134cb79026c6)
|
||||
|
||||
#### [v0.38.17](https://github.com/RhetTbull/osxphotos/compare/v0.38.16...v0.38.17)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
- Added --sidecar-drop-ext, issue #291 [`dce002c`](https://github.com/RhetTbull/osxphotos/commit/dce002cdfe12fa5fa4ada4d5097828a5375c2ecd)
|
||||
- Updated Template Substitution table [`7bd189e`](https://github.com/RhetTbull/osxphotos/commit/7bd189e9b22a2ad5a8a80deb7cb93c61be37c771)
|
||||
|
||||
#### [v0.38.16](https://github.com/RhetTbull/osxphotos/compare/v0.38.15...v0.38.16)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
- Added searchinfo templates, issue #302 [`0d086bf`](https://github.com/RhetTbull/osxphotos/commit/0d086bf85102ce78b3111c64bfa88673fbc19559)
|
||||
|
||||
#### [v0.38.15](https://github.com/RhetTbull/osxphotos/compare/v0.38.14...v0.38.15)
|
||||
|
||||
> 28 December 2020
|
||||
|
||||
- Added --sidecar exiftool, issue #303 [`d833c14`](https://github.com/RhetTbull/osxphotos/commit/d833c14ef4b3f9375a85034cf0fb0f85a68cabb4)
|
||||
- Refactored sidecar code [`ade98fc`](https://github.com/RhetTbull/osxphotos/commit/ade98fc15051684bfb54d0199d9c370481b70dcc)
|
||||
- Refactored export2 to use sidecar bit field [`0d66759`](https://github.com/RhetTbull/osxphotos/commit/0d66759b1c200f1ecda202e28c259f88fd3db599)
|
||||
|
||||
#### [v0.38.14](https://github.com/RhetTbull/osxphotos/compare/v0.38.13...v0.38.14)
|
||||
|
||||
> 27 December 2020
|
||||
|
||||
- Bug fix for --description-template, issue #304 [`4cc40d2`](https://github.com/RhetTbull/osxphotos/commit/4cc40d24cfb11ef8668c5d3c3bab40371fdd0436)
|
||||
|
||||
#### [v0.38.13](https://github.com/RhetTbull/osxphotos/compare/v0.38.12...v0.38.13)
|
||||
|
||||
> 27 December 2020
|
||||
|
||||
- Set XMP:Subject to match Keywords, issue #302 [`75888cd`](https://github.com/RhetTbull/osxphotos/commit/75888cd6633d3f0180d24fef4f6776986a136f0f)
|
||||
|
||||
#### [v0.38.12](https://github.com/RhetTbull/osxphotos/compare/v0.38.11...v0.38.12)
|
||||
|
||||
> 26 December 2020
|
||||
|
||||
- Fixed city/sub-locality for SearchInfo [`f9f699b`](https://github.com/RhetTbull/osxphotos/commit/f9f699ba3500d58494f955d4e5d8118e336e6a2c)
|
||||
|
||||
#### [v0.38.11](https://github.com/RhetTbull/osxphotos/compare/v0.38.9...v0.38.11)
|
||||
|
||||
> 26 December 2020
|
||||
|
||||
- Exposed SearchInfo, closes #121 [`#121`](https://github.com/RhetTbull/osxphotos/issues/121)
|
||||
- Added version to --verbose, closes #297 [`#297`](https://github.com/RhetTbull/osxphotos/issues/297)
|
||||
- Added --exportdb [`2a49255`](https://github.com/RhetTbull/osxphotos/commit/2a49255277d3c6bd3b0d5f8288afd7de7dab0320)
|
||||
- Updated README.md [`f469ccc`](https://github.com/RhetTbull/osxphotos/commit/f469cccc4b4561db7611c3e9abf5aefc3ab0f648)
|
||||
- Fixed help text [`f3b7134`](https://github.com/RhetTbull/osxphotos/commit/f3b7134af1e3d07fb956eaccccd9d60bd075d3bf)
|
||||
|
||||
#### [v0.38.9](https://github.com/RhetTbull/osxphotos/compare/v0.38.8...v0.38.9)
|
||||
|
||||
> 21 December 2020
|
||||
|
||||
- Added --exiftool-option to CLI, closes #298 [`#298`](https://github.com/RhetTbull/osxphotos/issues/298)
|
||||
|
||||
#### [v0.38.8](https://github.com/RhetTbull/osxphotos/compare/v0.38.7...v0.38.8)
|
||||
|
||||
> 21 December 2020
|
||||
|
||||
- remove duplicate keywords with --exiftool and --sidecar, closes #294 [`#294`](https://github.com/RhetTbull/osxphotos/issues/294)
|
||||
|
||||
#### [v0.38.7](https://github.com/RhetTbull/osxphotos/compare/v0.38.6...v0.38.7)
|
||||
|
||||
> 21 December 2020
|
||||
|
||||
- Added better exiftool error handling, closes #300 [`#300`](https://github.com/RhetTbull/osxphotos/issues/300)
|
||||
- README.md updates for tested versions [`8d1ccda`](https://github.com/RhetTbull/osxphotos/commit/8d1ccda0c897f84342caf612c1070d78bff421f5)
|
||||
- version bump [`ef94933`](https://github.com/RhetTbull/osxphotos/commit/ef94933dd87b9ad2a516163ca50a36753dacd55a)
|
||||
|
||||
#### [v0.38.6](https://github.com/RhetTbull/osxphotos/compare/v0.38.5...v0.38.6)
|
||||
|
||||
> 18 December 2020
|
||||
|
||||
- Documentation fix for #293. Thanks to @finestream [`#295`](https://github.com/RhetTbull/osxphotos/pull/295)
|
||||
- Added additional test cases for #286, --ignore-signature [`880a9b6`](https://github.com/RhetTbull/osxphotos/commit/880a9b67a14787ef23ae68ad3164d7eda1af16ec)
|
||||
- Add @finestream as a contributor [`ad860b1`](https://github.com/RhetTbull/osxphotos/commit/ad860b1500dffd846322e05562ba4f2019cd1017)
|
||||
- Fixed issue #296 [`a7c688c`](https://github.com/RhetTbull/osxphotos/commit/a7c688cfc2221833e0252d71bbe596eee5f9a6e8)
|
||||
- Updated README.md [`d40b16a`](https://github.com/RhetTbull/osxphotos/commit/d40b16a456c64014674505b7c715c80b977da76a)
|
||||
- Version bump [`4678f15`](https://github.com/RhetTbull/osxphotos/commit/4678f15bc86b5dedcb73c73f40e5fe11c1b51fa0)
|
||||
|
||||
#### [v0.38.5](https://github.com/RhetTbull/osxphotos/compare/v0.38.4...v0.38.5)
|
||||
|
||||
> 17 December 2020
|
||||
|
||||
- Patch 1 [`#1`](https://github.com/RhetTbull/osxphotos/pull/1)
|
||||
- Implemented --ignore-signature, issue #286 [`e394d8e`](https://github.com/RhetTbull/osxphotos/commit/e394d8e6be7607a1668029bcb37ccb30a4fa792f)
|
||||
- Update __main__.py [`e097f3a`](https://github.com/RhetTbull/osxphotos/commit/e097f3aad546b5be5eabab529bd2c35ce3056876)
|
||||
- Update README.md [`4f64eeb`](https://github.com/RhetTbull/osxphotos/commit/4f64eeb996d43953eb90618465d2bd046282c4bb)
|
||||
- Update README.md [`3155045`](https://github.com/RhetTbull/osxphotos/commit/3155045ec87d83285f2e66210559f4be0a10e3a2)
|
||||
|
||||
#### [v0.38.4](https://github.com/RhetTbull/osxphotos/compare/v0.38.3...v0.38.4)
|
||||
|
||||
> 14 December 2020
|
||||
|
||||
- Fix for issue #263 [`d5730dd`](https://github.com/RhetTbull/osxphotos/commit/d5730dd8ae92bc819b61ab4df9b10ae64e23569f)
|
||||
|
||||
#### [v0.38.3](https://github.com/RhetTbull/osxphotos/compare/v0.38.2...v0.38.3)
|
||||
|
||||
> 13 December 2020
|
||||
|
||||
- Fix for QuickTime date/time, issue #282 [`d8593a0`](https://github.com/RhetTbull/osxphotos/commit/d8593a01e210a0b914d5668ad5f70976fc43b217)
|
||||
|
||||
#### [v0.38.2](https://github.com/RhetTbull/osxphotos/compare/v0.38.0...v0.38.2)
|
||||
|
||||
> 12 December 2020
|
||||
|
||||
- Added --save-config, --load-config [`#290`](https://github.com/RhetTbull/osxphotos/pull/290)
|
||||
- removed extended_attributes reference [`6559c4d`](https://github.com/RhetTbull/osxphotos/commit/6559c4d8f64ad41df925182f9f24f6f67eecd1df)
|
||||
- This is why I never use branches [`baf45cc`](https://github.com/RhetTbull/osxphotos/commit/baf45ccd2aa24858bb1a8f95ef798121ee80af30)
|
||||
- Version bump [`aca85ee`](https://github.com/RhetTbull/osxphotos/commit/aca85ee2aa01fcdece0224332584082280a3f62c)
|
||||
- Merge branch 'master' into save_config [`9584a9c`](https://github.com/RhetTbull/osxphotos/commit/9584a9ccc56ac8c6dc5eb96019adf9224f436690)
|
||||
- Added tests for configoptions.py [`0262e0d`](https://github.com/RhetTbull/osxphotos/commit/0262e0d97e06ee36786b4491efa178608afb5de5)
|
||||
|
||||
#### [v0.38.0](https://github.com/RhetTbull/osxphotos/compare/v0.37.7...v0.38.0)
|
||||
|
||||
> 11 December 2020
|
||||
|
||||
- Initial implementation of configoptions for --save-config, --load-config [`22355fd`](https://github.com/RhetTbull/osxphotos/commit/22355fd44609f42e412c580dfc9e5e0b7cf6c464)
|
||||
- Refactoring of save-config/load-config code [`37b1e5c`](https://github.com/RhetTbull/osxphotos/commit/37b1e5ca472e9679301fa96d2b7fdd8c4ad438b2)
|
||||
- Refactored FileUtil to use copy-on-write no APFS, issue #287 [`ec4b53e`](https://github.com/RhetTbull/osxphotos/commit/ec4b53ed9dd2bc1e6b71349efdaf0b81c6d797e5)
|
||||
|
||||
#### [v0.37.7](https://github.com/RhetTbull/osxphotos/compare/v0.37.6...v0.37.7)
|
||||
|
||||
> 7 December 2020
|
||||
|
||||
- Fix for issue #262 [`11f563a`](https://github.com/RhetTbull/osxphotos/commit/11f563a47926798295e24872bc0efcaaba35906f)
|
||||
|
||||
#### [v0.37.6](https://github.com/RhetTbull/osxphotos/compare/v0.37.5...v0.37.6)
|
||||
|
||||
> 6 December 2020
|
||||
|
||||
- Added --cleanup, issue #262 [`e5d6f21`](https://github.com/RhetTbull/osxphotos/commit/e5d6f21d8e85f092fd0cc06ea4a0eaa12834c011)
|
||||
|
||||
#### [v0.37.5](https://github.com/RhetTbull/osxphotos/compare/v0.37.4...v0.37.5)
|
||||
|
||||
> 5 December 2020
|
||||
|
||||
- Fix for issue #257, #275 [`1b6a03a`](https://github.com/RhetTbull/osxphotos/commit/1b6a03a9f8c76cb5e50caab6eb138a56ccd841dd)
|
||||
|
||||
#### [v0.37.4](https://github.com/RhetTbull/osxphotos/compare/v0.37.3...v0.37.4)
|
||||
|
||||
> 5 December 2020
|
||||
|
||||
- Merge branch 'master' of github.com:RhetTbull/osxphotos [`69cd236`](https://github.com/RhetTbull/osxphotos/commit/69cd2367122a3a86044df2845e706d3510bdf2c1)
|
||||
- Implement fix for issue #282, QuickTime metadata [`4cce9d4`](https://github.com/RhetTbull/osxphotos/commit/4cce9d4939a00ad2d265a510a2c6f0c8e6a8c655)
|
||||
- Implement fix for issue #282, QuickTime metadata [`cfb07cb`](https://github.com/RhetTbull/osxphotos/commit/cfb07cbfafaac493f6221be482c432812534ddfa)
|
||||
|
||||
#### [v0.37.3](https://github.com/RhetTbull/osxphotos/compare/v0.37.2...v0.37.3)
|
||||
|
||||
> 30 November 2020
|
||||
|
||||
- Removed --use-photokit authorization check, issue 278 [`ed3a971`](https://github.com/RhetTbull/osxphotos/commit/ed3a9711dc0805aed1aacc30e01eeb9c1077d9e1)
|
||||
|
||||
#### [v0.37.2](https://github.com/RhetTbull/osxphotos/compare/v0.37.1...v0.37.2)
|
||||
|
||||
> 29 November 2020
|
||||
|
||||
- Catch errors in export_photo [`d9dcf09`](https://github.com/RhetTbull/osxphotos/commit/d9dcf0917a541725d1e472e7f918733e4e2613d0)
|
||||
- Added --missing to export, see issue #277 [`25eacc7`](https://github.com/RhetTbull/osxphotos/commit/25eacc7caddd6721232b3f77a02532fcd35f7836)
|
||||
|
||||
#### [v0.37.1](https://github.com/RhetTbull/osxphotos/compare/v0.37.0...v0.37.1)
|
||||
|
||||
> 28 November 2020
|
||||
|
||||
- Added --report option to CLI, implements #253 [`d22eaf3`](https://github.com/RhetTbull/osxphotos/commit/d22eaf39edc8b0b489b011d6d21345dcedcc8dff)
|
||||
- Updated template values [`af827d7`](https://github.com/RhetTbull/osxphotos/commit/af827d7a5769f41579d300a7cc511251d86b7eed)
|
||||
|
||||
#### [v0.37.0](https://github.com/RhetTbull/osxphotos/compare/v0.36.25...v0.37.0)
|
||||
|
||||
> 28 November 2020
|
||||
|
||||
- Added {exiftool} template, implements issue #259 [`48acb42`](https://github.com/RhetTbull/osxphotos/commit/48acb42631226a71bfc636eea2d3151f1b7165f4)
|
||||
|
||||
#### [v0.36.25](https://github.com/RhetTbull/osxphotos/compare/v0.36.24...v0.36.25)
|
||||
|
||||
> 27 November 2020
|
||||
|
||||
- Added --original-suffix for issue #263 [`399d432`](https://github.com/RhetTbull/osxphotos/commit/399d432a66354b9c235f30d10c6985fbde1b7e4f)
|
||||
|
||||
#### [v0.36.24](https://github.com/RhetTbull/osxphotos/compare/v0.36.23...v0.36.24)
|
||||
|
||||
> 26 November 2020
|
||||
|
||||
- Initial implementation for issue #265 [`382fca3`](https://github.com/RhetTbull/osxphotos/commit/382fca3f92a3c251c12426dd0dc6d7dc21b691cf)
|
||||
- More work on issue #265 [`d5a9f76`](https://github.com/RhetTbull/osxphotos/commit/d5a9f767199d25ebd9d5925d05ee39ea7e51ac26)
|
||||
- Simplified sidecar table in export_db [`0632a97`](https://github.com/RhetTbull/osxphotos/commit/0632a97f55af67c7e5265b0d3283155c7c087e89)
|
||||
|
||||
#### [v0.36.23](https://github.com/RhetTbull/osxphotos/compare/v0.36.22...v0.36.23)
|
||||
|
||||
> 26 November 2020
|
||||
|
||||
- Fix for missing original_filename, issue #267 [`fa33218`](https://github.com/RhetTbull/osxphotos/commit/fa332186ab3cdbe1bfd6496ff29b652ef984a5f8)
|
||||
- version bump [`b5195f9`](https://github.com/RhetTbull/osxphotos/commit/b5195f9d2b81cf6737b65e3cd3793ea9b0da13eb)
|
||||
- Updated test [`aa2ebf5`](https://github.com/RhetTbull/osxphotos/commit/aa2ebf55bb50eec14f86a532334b376e407f4bbc)
|
||||
|
||||
#### [v0.36.22](https://github.com/RhetTbull/osxphotos/compare/v0.36.21...v0.36.22)
|
||||
|
||||
> 26 November 2020
|
||||
|
||||
- Add XML escaping to XMP sidecar export, thanks to @jstrine for the fix! [`#272`](https://github.com/RhetTbull/osxphotos/pull/272)
|
||||
- Fix EXIF GPS format for XMP sidecar, thanks to @jstrine for the fix! [`#270`](https://github.com/RhetTbull/osxphotos/pull/270)
|
||||
- Continue even if the original filename is None, thanks to @jstrine for the fix! [`#268`](https://github.com/RhetTbull/osxphotos/pull/268)
|
||||
- Added test for missing original_filename [`116cb66`](https://github.com/RhetTbull/osxphotos/commit/116cb662fbddf9153f6858c6ea97dc7f65c77705)
|
||||
- Add @jstrine as a contributor [`7460bc8`](https://github.com/RhetTbull/osxphotos/commit/7460bc88fcc5e1e7435c9b9bcdf7ec9c7c5e39ea)
|
||||
- Escape characters which cause XML parsing issues [`c42050a`](https://github.com/RhetTbull/osxphotos/commit/c42050a10cac40b0b5ac70c587e07f257a9b50dd)
|
||||
- Fix tests for apostrophe [`d0d2e80`](https://github.com/RhetTbull/osxphotos/commit/d0d2e8080096bf66f93a830386800ce713680c51)
|
||||
- Fix test for XMP sidecar with GPS info [`c27cfb1`](https://github.com/RhetTbull/osxphotos/commit/c27cfb1223fa82b9e5549b93c283e9444693270a)
|
||||
|
||||
#### [v0.36.21](https://github.com/RhetTbull/osxphotos/compare/v0.36.20...v0.36.21)
|
||||
|
||||
> 25 November 2020
|
||||
|
||||
- Exposed --use-photos-export and --use-photokit [`e951e53`](https://github.com/RhetTbull/osxphotos/commit/e951e5361e59060229787bb1ea3fc4e088ffff99)
|
||||
|
||||
#### [v0.36.20](https://github.com/RhetTbull/osxphotos/compare/v0.36.19...v0.36.20)
|
||||
|
||||
> 23 November 2020
|
||||
|
||||
- Added photokit export as hidden --use-photokit option [`26f96d5`](https://github.com/RhetTbull/osxphotos/commit/26f96d582c01ce9816b1f54f0e74c8570f133f7c)
|
||||
|
||||
#### [v0.36.19](https://github.com/RhetTbull/osxphotos/compare/v0.36.18...v0.36.19)
|
||||
|
||||
> 19 November 2020
|
||||
|
||||
- Removed debug statement in _photoinfo_export [`8cb15d1`](https://github.com/RhetTbull/osxphotos/commit/8cb15d15551094dcaf1b0ef32d6ac0273be7fd37)
|
||||
|
||||
#### [v0.36.18](https://github.com/RhetTbull/osxphotos/compare/v0.36.17...v0.36.18)
|
||||
|
||||
> 14 November 2020
|
||||
|
||||
- Moved AppleScript to photoscript [`3c85f26`](https://github.com/RhetTbull/osxphotos/commit/3c85f26f901645ce297685ccd639792757fbc995)
|
||||
- Fixed missing data file for photoscript [`2d9429c`](https://github.com/RhetTbull/osxphotos/commit/2d9429c8eefabe6233fc580f65511c48ee6c01e5)
|
||||
- Version bump, updated requirements [`3b6dd08`](https://github.com/RhetTbull/osxphotos/commit/3b6dd08d2bb2b20a55064bf24fe7ce788e7268ef)
|
||||
|
||||
#### [v0.36.17](https://github.com/RhetTbull/osxphotos/compare/v0.36.15...v0.36.17)
|
||||
|
||||
> 12 November 2020
|
||||
|
||||
- Fixed path for photos actually missing off disk [`5d4d7d7`](https://github.com/RhetTbull/osxphotos/commit/5d4d7d7db7ca1109b6230803fe777d7a30882efe)
|
||||
- Fixed erroneous attempt to export edited with --download-missing [`8dc59cb`](https://github.com/RhetTbull/osxphotos/commit/8dc59cbc35c33e71d0d912f4139e855180ac4fbd)
|
||||
- version bump [`802e2f0`](https://github.com/RhetTbull/osxphotos/commit/802e2f069a5f8b37ddc6b3b8ba07519ce10f88a7)
|
||||
|
||||
#### [v0.36.15](https://github.com/RhetTbull/osxphotos/compare/v0.36.14...v0.36.15)
|
||||
|
||||
> 11 November 2020
|
||||
|
||||
- Avoid copying db files if not necessary [`ea9b41b`](https://github.com/RhetTbull/osxphotos/commit/ea9b41bae41a05aad53454f67871c5e6c9a49f79)
|
||||
|
||||
#### [v0.36.14](https://github.com/RhetTbull/osxphotos/compare/v0.36.13...v0.36.14)
|
||||
|
||||
> 9 November 2020
|
||||
|
||||
- Fix for issue #247 [`38397b5`](https://github.com/RhetTbull/osxphotos/commit/38397b507b456169cf3be2d2dc6743ec8653feb3)
|
||||
|
||||
#### [v0.36.13](https://github.com/RhetTbull/osxphotos/compare/v0.36.11...v0.36.13)
|
||||
|
||||
> 9 November 2020
|
||||
|
||||
- Refactored phototemplate.py to add PATH_SEP option [`3636fcb`](https://github.com/RhetTbull/osxphotos/commit/3636fcbc76100d9898a59f24ed6e9b1965cc6022)
|
||||
- More work on phototemplate.py to add inline expansion [`a6231e2`](https://github.com/RhetTbull/osxphotos/commit/a6231e29ff28b2c7dc3239445f41afcb35926a7a)
|
||||
|
||||
#### [v0.36.11](https://github.com/RhetTbull/osxphotos/compare/v0.36.10...v0.36.11)
|
||||
|
||||
> 8 November 2020
|
||||
|
||||
- Implemented boolean type template fields [`7fa3704`](https://github.com/RhetTbull/osxphotos/commit/7fa3704840f7800689b4ac5f8edee8210eb3e8db)
|
||||
- Bug fix in handling missing edited photos [`e829212`](https://github.com/RhetTbull/osxphotos/commit/e829212987bbc1a88f845922abcffef70c159883)
|
||||
- Fixed message in CLI [`df37a01`](https://github.com/RhetTbull/osxphotos/commit/df37a017a8efdc8d0b9bc8d00a4452dc4cb892b3)
|
||||
|
||||
#### [v0.36.10](https://github.com/RhetTbull/osxphotos/compare/v0.36.9...v0.36.10)
|
||||
|
||||
> 8 November 2020
|
||||
|
||||
- Implemented issue #255 [`ae2fd2e`](https://github.com/RhetTbull/osxphotos/commit/ae2fd2e3db984756e6cc3f7b3338b8ba819ce28c)
|
||||
|
||||
#### [v0.36.9](https://github.com/RhetTbull/osxphotos/compare/v0.36.8...v0.36.9)
|
||||
|
||||
> 7 November 2020
|
||||
|
||||
- Refactored regex in phototemplate [`653b7e6`](https://github.com/RhetTbull/osxphotos/commit/653b7e6600e0738ecd00f74d510a893e0d447ca4)
|
||||
- Fix for exporting slow mo videos, issue #252 [`9d38885`](https://github.com/RhetTbull/osxphotos/commit/9d38885416b528bd8c91bb09120be85a8b109f29)
|
||||
|
||||
#### [v0.36.8](https://github.com/RhetTbull/osxphotos/compare/v0.36.7...v0.36.8)
|
||||
|
||||
> 5 November 2020
|
||||
|
||||
- Refactored exiftool.py [`2202f1b`](https://github.com/RhetTbull/osxphotos/commit/2202f1b1e9c4f83558ef48e58cb94af6b3a38cdd)
|
||||
- README.md update [`a509ef1`](https://github.com/RhetTbull/osxphotos/commit/a509ef18d3db2ac15a661e763a7254974cf8d84a)
|
||||
|
||||
#### [v0.36.7](https://github.com/RhetTbull/osxphotos/compare/v0.36.6...v0.36.7)
|
||||
|
||||
> 4 November 2020
|
||||
|
||||
- Implemented context manager for ExifTool, closes #250 [`#250`](https://github.com/RhetTbull/osxphotos/issues/250)
|
||||
|
||||
#### [v0.36.6](https://github.com/RhetTbull/osxphotos/compare/v0.36.5...v0.36.6)
|
||||
|
||||
> 2 November 2020
|
||||
|
||||
- Fix for issue #39 [`c7c5320`](https://github.com/RhetTbull/osxphotos/commit/c7c5320587e31070b55cc8c7e74f30b0f9e61379)
|
||||
|
||||
#### [v0.36.5](https://github.com/RhetTbull/osxphotos/compare/v0.36.4...v0.36.5)
|
||||
|
||||
> 1 November 2020
|
||||
|
||||
- Added --ignore-date-modified flag, issue #247 [`663e33b`](https://github.com/RhetTbull/osxphotos/commit/663e33bc1709f767e1a08242f6bfe86a3fc78552)
|
||||
|
||||
#### [v0.36.4](https://github.com/RhetTbull/osxphotos/compare/v0.36.2...v0.36.4)
|
||||
|
||||
> 1 November 2020
|
||||
|
||||
- Updated --exiftool to set dates/times as Photos does, issue #247 [`11459d1`](https://github.com/RhetTbull/osxphotos/commit/11459d1da4d7d13e36e9db4bdc940b74baad9d11)
|
||||
- Partial fix for issue #247 on Mojave [`6ac3111`](https://github.com/RhetTbull/osxphotos/commit/6ac311199e9f7afe6170cbbd68ceaa1bb9f0682b)
|
||||
- Add @mwort as a contributor [`9cff8e8`](https://github.com/RhetTbull/osxphotos/commit/9cff8e89c6e939d3d371a4f60649f6e5595a55b9)
|
||||
|
||||
#### [v0.36.2](https://github.com/RhetTbull/osxphotos/compare/v0.36.1...v0.36.2)
|
||||
|
||||
> 31 October 2020
|
||||
|
||||
- Fixed handling of date_modified for Catalina, issue #247 [`0cce234`](https://github.com/RhetTbull/osxphotos/commit/0cce234a8cbba63dc1cba439c06fe9de078ff480)
|
||||
|
||||
#### [v0.36.1](https://github.com/RhetTbull/osxphotos/compare/v0.36.0...v0.36.1)
|
||||
|
||||
> 30 October 2020
|
||||
|
||||
- Added --has-comment/--has-likes to CLI, issue #240 [`c5dba8c`](https://github.com/RhetTbull/osxphotos/commit/c5dba8c89bba35d7a77e087b180b2a3d7b94280a)
|
||||
- Cleaned up as_dict/asdict, issue #144, #188 [`603dabb`](https://github.com/RhetTbull/osxphotos/commit/603dabb8f420a89e993d5aadcd3a5614bbb262dd)
|
||||
- Updated README.md [`d16932d`](https://github.com/RhetTbull/osxphotos/commit/d16932d0fd8d160ccf44e9842329d5933dc25b36)
|
||||
|
||||
#### [v0.36.0](https://github.com/RhetTbull/osxphotos/compare/v0.35.7...v0.36.0)
|
||||
|
||||
> 26 October 2020
|
||||
|
||||
- Added verbose to PhotosDB(), partial fix for #110 [`d87b8f3`](https://github.com/RhetTbull/osxphotos/commit/d87b8f30a45cbb6fdb315a12f8585e2bdc21be6b)
|
||||
- Added comments/likes, implements #214 [`23de6b5`](https://github.com/RhetTbull/osxphotos/commit/23de6b58908371d9ca55d1d1999c6d56de454180)
|
||||
- Cleaned up constructor for PhotosDB [`667c89e`](https://github.com/RhetTbull/osxphotos/commit/667c89e32c3f96baeafebc03e83517ea05693b00)
|
||||
|
||||
#### [v0.35.7](https://github.com/RhetTbull/osxphotos/compare/v0.35.6...v0.35.7)
|
||||
|
||||
> 24 October 2020
|
||||
|
||||
- Fix for issue #238 [`48f29e1`](https://github.com/RhetTbull/osxphotos/commit/48f29e138e4e9da3eba78f3681ee9b8cb28910df)
|
||||
|
||||
#### [v0.35.6](https://github.com/RhetTbull/osxphotos/compare/v0.35.5...v0.35.6)
|
||||
|
||||
> 24 October 2020
|
||||
|
||||
- Fixed shared, not_shared in cli [`8551981`](https://github.com/RhetTbull/osxphotos/commit/8551981f68f0cd2a3a081cc21ae287ff981b9b4b)
|
||||
|
||||
#### [v0.35.5](https://github.com/RhetTbull/osxphotos/compare/v0.35.4...v0.35.5)
|
||||
|
||||
> 22 October 2020
|
||||
|
||||
- Added get_shared_photo_comments.py to examples [`15e0914`](https://github.com/RhetTbull/osxphotos/commit/15e0914af6301a945bc751173aef6718487d9637)
|
||||
- Fix for issue #237 [`a416de2`](https://github.com/RhetTbull/osxphotos/commit/a416de29e4ac39a5c323f7913b05a8c38ad205be)
|
||||
- Added test for issue #235 [`ea68229`](https://github.com/RhetTbull/osxphotos/commit/ea68229ddac2e2301ac2d5607451cf7d00207d5d)
|
||||
|
||||
#### [v0.35.4](https://github.com/RhetTbull/osxphotos/compare/v0.35.3...v0.35.4)
|
||||
|
||||
> 18 October 2020
|
||||
|
||||
- refactored template code to fix #213 [`#213`](https://github.com/RhetTbull/osxphotos/issues/213)
|
||||
|
||||
#### [v0.35.3](https://github.com/RhetTbull/osxphotos/compare/v0.35.2...v0.35.3)
|
||||
|
||||
> 15 October 2020
|
||||
|
||||
- Fix for issue #235, #236 [`41b2399`](https://github.com/RhetTbull/osxphotos/commit/41b23991df3d1d553b70889ede237f83b6874519)
|
||||
|
||||
#### [v0.35.2](https://github.com/RhetTbull/osxphotos/compare/v0.35.1...v0.35.2)
|
||||
|
||||
> 12 October 2020
|
||||
|
||||
- Fix for issue #234 [`da100f9`](https://github.com/RhetTbull/osxphotos/commit/da100f93a9b849ca4750336d7f90e9023e39dd07)
|
||||
|
||||
#### [v0.35.1](https://github.com/RhetTbull/osxphotos/compare/v0.35.0...v0.35.1)
|
||||
|
||||
> 12 October 2020
|
||||
|
||||
- Fix for issue #230 [`dcbf8f2`](https://github.com/RhetTbull/osxphotos/commit/dcbf8f25f61e21bcf1040046aa9d6ddba4ac9735)
|
||||
|
||||
#### [v0.35.0](https://github.com/RhetTbull/osxphotos/compare/v0.34.5...v0.35.0)
|
||||
|
||||
> 12 October 2020
|
||||
|
||||
- Convert to jpeg [`#233`](https://github.com/RhetTbull/osxphotos/pull/233)
|
||||
- Updated tests, closes #231 [`#231`](https://github.com/RhetTbull/osxphotos/issues/231)
|
||||
- Updated tests [`b0171ba`](https://github.com/RhetTbull/osxphotos/commit/b0171ba6f5b73e1ff71e16d27852f8df7f208f60)
|
||||
- Updated tests [`07b0843`](https://github.com/RhetTbull/osxphotos/commit/07b08433df5a60f191e23a95394e83e51dca016f)
|
||||
- Merge branch 'master' into convert_to_jpeg [`fe5185b`](https://github.com/RhetTbull/osxphotos/commit/fe5185be8893002da663039f8ec103faed0f1831)
|
||||
- Added israw, tests for Big Sur [`b5a9794`](https://github.com/RhetTbull/osxphotos/commit/b5a9794f6bff5683fd42a22197454940e4d7ba88)
|
||||
- Updates to path, path_raw, uti for RAW+JPEG pairs [`b32f4b8`](https://github.com/RhetTbull/osxphotos/commit/b32f4b8504768a5f4b5ad54c00315b9e82fca980)
|
||||
|
||||
#### [v0.34.5](https://github.com/RhetTbull/osxphotos/compare/v0.34.3...v0.34.5)
|
||||
|
||||
> 6 October 2020
|
||||
|
||||
- --convert-to-jpeg initial version working [`38f201d`](https://github.com/RhetTbull/osxphotos/commit/38f201d0fb70bf299a828c1dd0d034a119e380c4)
|
||||
- Added tests, fixed bug in export_db [`5a13605`](https://github.com/RhetTbull/osxphotos/commit/5a13605f850bb947c8888246f06a5ca4e6aa5f10)
|
||||
- Updated tests [`b2b39aa`](https://github.com/RhetTbull/osxphotos/commit/b2b39aa6075df11861cf5d8945b657204f120e87)
|
||||
|
||||
#### [v0.34.3](https://github.com/RhetTbull/osxphotos/compare/v0.34.2...v0.34.3)
|
||||
|
||||
> 29 September 2020
|
||||
|
||||
- Update exiftool.py to preserve file modification time, thanks to @hhoeck [`#223`](https://github.com/RhetTbull/osxphotos/pull/223)
|
||||
- Added tests for 10.15.6 [`432da7f`](https://github.com/RhetTbull/osxphotos/commit/432da7f139a5e4b37eeb358f4ede45314407f8e5)
|
||||
- Added HEIC test image [`ddc1e69`](https://github.com/RhetTbull/osxphotos/commit/ddc1e69b4a4ac712e1af312b865c4216f9ad350c)
|
||||
- Fixed bug related to issue #222 [`c939df7`](https://github.com/RhetTbull/osxphotos/commit/c939df717159e8b97955c0b267327cd56a9ed56c)
|
||||
- Version bump for bug fix [`62d54cc`](https://github.com/RhetTbull/osxphotos/commit/62d54cc0beabd0141545608184d4b2c658eedf0f)
|
||||
- Update README.md [`6883fec`](https://github.com/RhetTbull/osxphotos/commit/6883fec2b2236d892b88327e1b4e9da1237f7dea)
|
||||
|
||||
#### [v0.34.2](https://github.com/RhetTbull/osxphotos/compare/v0.34.1...v0.34.2)
|
||||
|
||||
> 14 September 2020
|
||||
|
||||
- Partial fix for issue #213 [`459d91d`](https://github.com/RhetTbull/osxphotos/commit/459d91d7b11dbd4b0564906c1689b60dc5b64642)
|
||||
|
||||
#### [v0.34.1](https://github.com/RhetTbull/osxphotos/compare/v0.34.0...v0.34.1)
|
||||
|
||||
> 13 September 2020
|
||||
|
||||
- Fixed exception handling in export [`eb00ffd`](https://github.com/RhetTbull/osxphotos/commit/eb00ffd73737ef4832229e4e6fd8dc4ccb0b8539)
|
||||
- Updated README.md [`a1776fa`](https://github.com/RhetTbull/osxphotos/commit/a1776fa14850275ad6b02ece80bbe8ce908fa836)
|
||||
|
||||
#### [v0.34.0](https://github.com/RhetTbull/osxphotos/compare/v0.33.8...v0.34.0)
|
||||
|
||||
> 7 September 2020
|
||||
|
||||
- Added --skip-original-if-edited for issue #159 [`5f2d401`](https://github.com/RhetTbull/osxphotos/commit/5f2d401048850fd68f31b37a7e71abc11ca80dc5)
|
||||
- Still working on issue #208 [`58b3869`](https://github.com/RhetTbull/osxphotos/commit/58b3869a7cce7cb3f211599e544d7e5426ceb4a6)
|
||||
|
||||
#### [v0.33.8](https://github.com/RhetTbull/osxphotos/compare/v0.33.7...v0.33.8)
|
||||
|
||||
> 31 August 2020
|
||||
|
||||
- Fixed sidecar collisions, closes #210 [`#210`](https://github.com/RhetTbull/osxphotos/issues/210)
|
||||
|
||||
#### [v0.33.7](https://github.com/RhetTbull/osxphotos/compare/v0.33.5...v0.33.7)
|
||||
|
||||
> 31 August 2020
|
||||
|
||||
- typo fix - thanks to @dmd [`#212`](https://github.com/RhetTbull/osxphotos/pull/212)
|
||||
- Normalize unicode for issue #208 [`a36eb41`](https://github.com/RhetTbull/osxphotos/commit/a36eb416b19284477922b6a5f837f4040327138b)
|
||||
- Added force_download.py to examples [`b611d34`](https://github.com/RhetTbull/osxphotos/commit/b611d34d19db480af72f57ef55eacd0a32c8d1e8)
|
||||
- Added photoshop:SidecarForExtension to XMP, partial fix for #210 [`60d96a8`](https://github.com/RhetTbull/osxphotos/commit/60d96a8f563882fba2365a6ab58c1276725eedaa)
|
||||
- Updated README.md [`c9b1518`](https://github.com/RhetTbull/osxphotos/commit/c9b15186a022d91248451279e5f973e3f2dca4b4)
|
||||
- Update README.md [`42e8fba`](https://github.com/RhetTbull/osxphotos/commit/42e8fba125a3c6b1bd0d538f2af511aabfbeb478)
|
||||
|
||||
#### [v0.33.5](https://github.com/RhetTbull/osxphotos/compare/v0.33.3...v0.33.5)
|
||||
|
||||
> 25 August 2020
|
||||
|
||||
- Fixed DST handling for from_date/to_date, closes #193 (again) [`#193`](https://github.com/RhetTbull/osxphotos/issues/193)
|
||||
- Added raw timestamps to PhotoInfo._info [`0f457a4`](https://github.com/RhetTbull/osxphotos/commit/0f457a4082a4eebc42a5df2160a02ad987b6f96c)
|
||||
|
||||
#### [v0.33.3](https://github.com/RhetTbull/osxphotos/compare/v0.33.2...v0.33.3)
|
||||
|
||||
> 23 August 2020
|
||||
|
||||
- Fixed portrait for Catalina/Big Sur; see issue #203 [`1f717b0`](https://github.com/RhetTbull/osxphotos/commit/1f717b05794c2088c7c15d2aab0c5d24b6309c06)
|
||||
|
||||
#### [v0.33.2](https://github.com/RhetTbull/osxphotos/compare/v0.33.0...v0.33.2)
|
||||
|
||||
> 23 August 2020
|
||||
|
||||
- Closes issue #206, adds --touch-file [`#207`](https://github.com/RhetTbull/osxphotos/pull/207)
|
||||
- Touch files - fixes #194 -- thanks to @PabloKohan [`#205`](https://github.com/RhetTbull/osxphotos/pull/205)
|
||||
- Refactor/cleanup _export_photo - thanks to @PabloKohan [`#204`](https://github.com/RhetTbull/osxphotos/pull/204)
|
||||
- Finished --touch-file, closes #206 [`#206`](https://github.com/RhetTbull/osxphotos/issues/206)
|
||||
- Merge pull request #205 from PabloKohan/touch_files__fix_194 [`#194`](https://github.com/RhetTbull/osxphotos/issues/194)
|
||||
- --touch-file now working with --update [`6c11e3f`](https://github.com/RhetTbull/osxphotos/commit/6c11e3fa5b5b05b98b9fdbb0e59e3a78c7dff980)
|
||||
- Refactor/cleanup _export_photo [`eefa1f1`](https://github.com/RhetTbull/osxphotos/commit/eefa1f181f4fd7b027ae69abd2b764afb590c081)
|
||||
- Fixed touch tests [`1bf7105`](https://github.com/RhetTbull/osxphotos/commit/1bf7105737fbd756064a2f9ef4d4bbd0b067978c)
|
||||
- Working on issue 206 [`ebd878a`](https://github.com/RhetTbull/osxphotos/commit/ebd878a075983ef3df0b1ead1a725e01508721f8)
|
||||
- Working on issue #206 [`c9c9202`](https://github.com/RhetTbull/osxphotos/commit/c9c920220545dc27c8cb1379d7bde15987cce72c)
|
||||
|
||||
#### [v0.33.0](https://github.com/RhetTbull/osxphotos/compare/v0.32.0...v0.33.0)
|
||||
|
||||
> 17 August 2020
|
||||
|
||||
42
examples/force_download.py
Normal file
@@ -0,0 +1,42 @@
|
||||
""" use osxphotos to force the download of photos from iCloud
|
||||
downloads images to a temporary directory then deletes them
|
||||
resulting in the photo being downloaded to Photos library
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
def main():
|
||||
photosdb = osxphotos.PhotosDB()
|
||||
tempdir = tempfile.TemporaryDirectory()
|
||||
photos = photosdb.photos()
|
||||
downloaded = 0
|
||||
missing = [photo for photo in photos if photo.ismissing and not photo.shared]
|
||||
|
||||
if not missing:
|
||||
print(f"Did not find any missing photos to download")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Downloading {len(missing)} photos")
|
||||
for photo in missing:
|
||||
if photo.ismissing:
|
||||
print(f"Downloading photo {photo.original_filename}")
|
||||
downloaded += 1
|
||||
exported = photo.export(tempdir.name, use_photos_export=True, timeout=300)
|
||||
if photo.hasadjustments:
|
||||
exported.extend(
|
||||
photo.export(tempdir.name, use_photos_export=True, edited=True, timeout=300)
|
||||
)
|
||||
for filename in exported:
|
||||
print(f"Removing temporary file {filename}")
|
||||
os.unlink(filename)
|
||||
print(f"Downloaded {downloaded} photos")
|
||||
tempdir.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
156
examples/get_shared_photo_comments.py
Normal file
@@ -0,0 +1,156 @@
|
||||
""" get shared comments associated with a photo """
|
||||
|
||||
import datetime
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import TIME_DELTA
|
||||
|
||||
|
||||
@dataclass
|
||||
class Comment:
|
||||
""" Class for shared photo comments """
|
||||
|
||||
uuid: str
|
||||
sort_fok: int
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
ismine: bool
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Like:
|
||||
""" Class for shared photo likes """
|
||||
|
||||
uuid: str
|
||||
sort_fok: int
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
ismine: bool
|
||||
|
||||
|
||||
def get_shared_person_info(photosdb, hashed_person_id):
|
||||
""" returns tuple of (first name, last name, full name)
|
||||
for person invited to shared album with
|
||||
ZINVITEEHASHEDPERSONID = hashed_person_id
|
||||
|
||||
Args:
|
||||
photosdb: a osxphotos.PhotosDB object
|
||||
hashed_person_id: str, value of ZINVITEEHASHEDPERSONID to lookup
|
||||
"""
|
||||
|
||||
conn, _ = photosdb.get_db_connection()
|
||||
results = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
ZINVITEEHASHEDPERSONID,
|
||||
ZINVITEEFIRSTNAME,
|
||||
ZINVITEELASTNAME,
|
||||
ZINVITEEFULLNAME
|
||||
FROM
|
||||
ZCLOUDSHAREDALBUMINVITATIONRECORD
|
||||
WHERE
|
||||
ZINVITEEHASHEDPERSONID = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
([hashed_person_id]),
|
||||
).fetchall()
|
||||
|
||||
if results:
|
||||
row = results[0]
|
||||
return (row[1], row[2], row[3])
|
||||
else:
|
||||
return (None, None, None)
|
||||
|
||||
|
||||
def get_comments(photosdb, uuid):
|
||||
""" return comments and likes, if any, for photo with uuid
|
||||
|
||||
Args:
|
||||
photosdb: a osxphotos.PhotosDB object
|
||||
uuid: uuid of the photo
|
||||
|
||||
Returns:
|
||||
tuple of (list of comments as Comment objects or [] if no comments, list of likes as Like objects or [] if no likes)
|
||||
"""
|
||||
conn, _ = photosdb.get_db_connection()
|
||||
|
||||
results = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
ZGENERICASSET.ZUUID, --0: UUID of the photo
|
||||
ZCLOUDSHAREDCOMMENT.ZISLIKE, --1: comment is actually a "like"
|
||||
ZCLOUDSHAREDCOMMENT.Z_FOK_COMMENTEDASSET, --2: sort order for comments on a photo
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, --3: date of comment
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, --4: text of comment
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, --5: hashed ID of person who made comment/like
|
||||
ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT --6: is my (this user's) comment
|
||||
FROM ZCLOUDSHAREDCOMMENT
|
||||
JOIN ZGENERICASSET ON
|
||||
ZGENERICASSET.Z_PK = ZCLOUDSHAREDCOMMENT.ZCOMMENTEDASSET
|
||||
OR
|
||||
ZGENERICASSET.Z_PK = ZCLOUDSHAREDCOMMENT.ZLIKEDASSET
|
||||
WHERE ZGENERICASSET.ZUUID = ?
|
||||
""",
|
||||
([uuid]),
|
||||
).fetchall()
|
||||
|
||||
comments = []
|
||||
likes = []
|
||||
for row in results:
|
||||
photo_uuid = row[0]
|
||||
sort_fok = row[2] or 0 # sort_fok is Null/None for likes
|
||||
is_like = bool(row[1])
|
||||
text = row[4]
|
||||
user_info = get_shared_person_info(photosdb, row[5])
|
||||
try:
|
||||
dt = datetime.datetime.fromtimestamp(row[3] + TIME_DELTA)
|
||||
except:
|
||||
dt = datetime.datetime(1970, 1, 1)
|
||||
ismine = bool(row[6])
|
||||
if is_like:
|
||||
# it's a like
|
||||
likes.append(Like(photo_uuid, sort_fok, dt, user_info[2], ismine))
|
||||
elif text:
|
||||
# comment
|
||||
comments.append(
|
||||
Comment(photo_uuid, sort_fok, dt, user_info[2], ismine, text)
|
||||
)
|
||||
if likes:
|
||||
likes.sort(key=lambda x: x.datetime)
|
||||
if comments:
|
||||
comments.sort(key=lambda x: x.sort_fok)
|
||||
return (comments, likes)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1:
|
||||
# library as first argument
|
||||
photosdb = osxphotos.PhotosDB(dbfile=sys.argv[1])
|
||||
else:
|
||||
# open default library
|
||||
photosdb = osxphotos.PhotosDB()
|
||||
|
||||
# shared albums
|
||||
shared_albums = photosdb.album_info_shared
|
||||
for album in shared_albums:
|
||||
print(f"Processing album {album.title}")
|
||||
# only shared albums can have comments
|
||||
for photo in album.photos:
|
||||
comments, likes = get_comments(photosdb, photo.uuid)
|
||||
if comments or likes:
|
||||
print(f"{photo.uuid}, {photo.original_filename}: ")
|
||||
if likes:
|
||||
print("Likes:")
|
||||
for like in likes:
|
||||
print(like)
|
||||
if comments:
|
||||
print("Comments:")
|
||||
for comment in comments:
|
||||
print(comment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -42,7 +42,7 @@ def main():
|
||||
if db:
|
||||
print("loading database")
|
||||
tic = time.perf_counter()
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=print)
|
||||
toc = time.perf_counter()
|
||||
print(f"done: took {toc-tic} seconds")
|
||||
return photosdb
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
# If you need to install pyinstaller:
|
||||
# python3 -m pip install --upgrade pyinstaller
|
||||
|
||||
pyinstaller --onefile --hidden-import="pkg_resources.py2_warn" --name osxphotos --add-data osxphotos/templates/xmp_sidecar.mako:osxphotos/templates cli.py
|
||||
pyinstaller osxphotos.spec
|
||||
48
osxphotos.spec
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# spec file for pyinstaller
|
||||
# run `pyinstaller osxphotos.spec`
|
||||
|
||||
import os
|
||||
import importlib
|
||||
|
||||
pathex = os.getcwd()
|
||||
|
||||
# include necessary data files
|
||||
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates')]
|
||||
package_imports = [['photoscript', ['photoscript.applescript']]]
|
||||
for package, files in package_imports:
|
||||
proot = os.path.dirname(importlib.import_module(package).__file__)
|
||||
datas.extend((os.path.join(proot, f), package) for f in files)
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(['cli.py'],
|
||||
pathex=[pathex],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=['pkg_resources.py2_warn'],
|
||||
hookspath=[],
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data,
|
||||
cipher=block_cipher)
|
||||
|
||||
exe = EXE(pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='osxphotos',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True )
|
||||
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
|
||||
from ._version import __version__
|
||||
from .photoinfo import PhotoInfo
|
||||
from .photosdb import PhotosDB
|
||||
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
|
||||
from .phototemplate import PhotoTemplate
|
||||
from .utils import _debug, _get_logger, _set_debug
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ from datetime import datetime
|
||||
# Apple Epoch is Jan 1, 2001
|
||||
TIME_DELTA = (datetime(2001, 1, 1, 0, 0) - datetime(1970, 1, 1, 0, 0)).total_seconds()
|
||||
|
||||
# Unicode format to use for comparing strings
|
||||
UNICODE_FORMAT = "NFC"
|
||||
|
||||
# which Photos library database versions have been tested
|
||||
# Photos 2.0 (10.12.6) == 2622
|
||||
# Photos 3.0 (10.13.6) == 3301
|
||||
@@ -99,3 +102,96 @@ _OSXPHOTOS_NONE_SENTINEL = "OSXPhotosXYZZY42_Sentinel$"
|
||||
|
||||
# SearchInfo categories for Photos 5, corresponds to categories in database/search/psi.sqlite
|
||||
SEARCH_CATEGORY_LABEL = 2024
|
||||
SEARCH_CATEGORY_PLACE_NAME = 1
|
||||
SEARCH_CATEGORY_STREET = 2
|
||||
SEARCH_CATEGORY_NEIGHBORHOOD = 3
|
||||
SEARCH_CATEGORY_LOCALITY_4 = 4
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_5 = 5
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_6 = 6
|
||||
SEARCH_CATEGORY_CITY = 7
|
||||
SEARCH_CATEGORY_LOCALITY_8 = 8
|
||||
SEARCH_CATEGORY_NAMED_AREA = 9
|
||||
SEARCH_CATEGORY_ALL_LOCALITY = [
|
||||
SEARCH_CATEGORY_LOCALITY_4,
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_5,
|
||||
SEARCH_CATEGORY_SUB_LOCALITY_6,
|
||||
SEARCH_CATEGORY_LOCALITY_8,
|
||||
SEARCH_CATEGORY_NAMED_AREA,
|
||||
]
|
||||
SEARCH_CATEGORY_STATE = 10
|
||||
SEARCH_CATEGORY_STATE_ABBREVIATION = 11
|
||||
SEARCH_CATEGORY_COUNTRY = 12
|
||||
SEARCH_CATEGORY_BODY_OF_WATER = 14
|
||||
SEARCH_CATEGORY_MONTH = 1014
|
||||
SEARCH_CATEGORY_YEAR = 1015
|
||||
SEARCH_CATEGORY_KEYWORDS = 2016
|
||||
SEARCH_CATEGORY_TITLE = 2017
|
||||
SEARCH_CATEGORY_DESCRIPTION = 2018
|
||||
SEARCH_CATEGORY_HOME = 2020
|
||||
SEARCH_CATEGORY_PERSON = 2021
|
||||
SEARCH_CATEGORY_ACTIVITY = 2027
|
||||
SEARCH_CATEGORY_HOLIDAY = 2029
|
||||
SEARCH_CATEGORY_SEASON = 2030
|
||||
SEARCH_CATEGORY_WORK = 2036
|
||||
SEARCH_CATEGORY_VENUE = 2038
|
||||
SEARCH_CATEGORY_VENUE_TYPE = 2039
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_VIDEO = 2044
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SLOMO = 2045
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_LIVE = 2046
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT = 2047
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA = 2048
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE = 2049
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_BURSTS = 2052
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT = 2053
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SELFIES = 2054
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES = 2055
|
||||
SEARCH_CATEGORY_MEDIA_TYPES = [
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_VIDEO,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SLOMO,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_LIVE,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SCREENSHOT,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_PANORAMA,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_TIMELAPSE,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_BURSTS,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_PORTRAIT,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_SELFIES,
|
||||
SEARCH_CATEGORY_PHOTO_TYPE_FAVORITES,
|
||||
]
|
||||
SEARCH_CATEGORY_PHOTO_NAME = 2056
|
||||
|
||||
|
||||
# Max filename length on MacOS
|
||||
MAX_FILENAME_LEN = 255
|
||||
|
||||
# Max directory name length on MacOS
|
||||
MAX_DIRNAME_LEN = 255
|
||||
|
||||
# Default JPEG quality when converting to JPEG
|
||||
DEFAULT_JPEG_QUALITY = 1.0
|
||||
|
||||
# Default suffix to add to edited images
|
||||
DEFAULT_EDITED_SUFFIX = "_edited"
|
||||
|
||||
# Default suffix to add to original images
|
||||
DEFAULT_ORIGINAL_SUFFIX = ""
|
||||
|
||||
# Colors for print CLI messages
|
||||
CLI_COLOR_ERROR = "red"
|
||||
CLI_COLOR_WARNING = "yellow"
|
||||
|
||||
# Bit masks for --sidecar
|
||||
SIDECAR_JSON = 0x1
|
||||
SIDECAR_EXIFTOOL = 0x2
|
||||
SIDECAR_XMP = 0x4
|
||||
|
||||
# supported attributes for --xattr-template
|
||||
EXTENDED_ATTRIBUTE_NAMES = [
|
||||
"authors",
|
||||
"comment",
|
||||
"copyright",
|
||||
"description",
|
||||
"findercomment",
|
||||
"headline",
|
||||
"keywords",
|
||||
]
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.33.5"
|
||||
__version__ = "0.39.4"
|
||||
|
||||
|
||||
|
||||
173
osxphotos/configoptions.py
Normal file
@@ -0,0 +1,173 @@
|
||||
""" ConfigOptions class to load/save config settings for osxphotos CLI """
|
||||
import toml
|
||||
|
||||
|
||||
class ConfigOptionsException(Exception):
|
||||
""" Invalid combination of options. """
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ConfigOptionsInvalidError(ConfigOptionsException):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigOptionsLoadError(ConfigOptionsException):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigOptions:
|
||||
""" data class to store and load options for osxphotos commands """
|
||||
|
||||
def __init__(self, name, attrs, ignore=None):
|
||||
""" init ConfigOptions class
|
||||
|
||||
Args:
|
||||
name: name for these options, will be used for section heading in TOML file when saving/loading from file
|
||||
attrs: dict with name and default value for all allowed attributes
|
||||
ignore: optional list of strings of keys to ignore from attrs dict
|
||||
"""
|
||||
self._name = name
|
||||
self._attrs = attrs.copy()
|
||||
if ignore:
|
||||
for attrname in ignore:
|
||||
self._attrs.pop(attrname, None)
|
||||
|
||||
self.set_attributes(attrs)
|
||||
|
||||
def set_attributes(self, args):
|
||||
for attr in self._attrs:
|
||||
try:
|
||||
arg = args[attr]
|
||||
# don't test 'not arg'; need to handle empty strings as valid values
|
||||
if arg is None or arg == False:
|
||||
if type(self._attrs[attr]) == tuple:
|
||||
setattr(self, attr, ())
|
||||
else:
|
||||
setattr(self, attr, self._attrs[attr])
|
||||
else:
|
||||
setattr(self, attr, arg)
|
||||
except KeyError:
|
||||
raise KeyError(f"Missing argument: {attr}")
|
||||
|
||||
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
|
||||
""" validate combinations of otions
|
||||
|
||||
Args:
|
||||
exclusive: list of tuples in form [("option_1", "option_2")...] which are exclusive;
|
||||
ie. either option_1 can be set or option_2 but not both;
|
||||
inclusive: list of tuples in form [("option_1", "option_2")...] which are inclusive;
|
||||
ie. if either option_1 or option_2 is set, the other must be set
|
||||
dependent: list of tuples in form [("option_1", ("option_2", "option_3"))...]
|
||||
where if option_1 is set, then at least one of the options in the second tuple must also be set
|
||||
cli: bool, set to True if called to validate CLI options;
|
||||
will prepend '--' to option names in InvalidOptions.message and change _ to - in option names
|
||||
|
||||
Returns:
|
||||
True if all options valid
|
||||
|
||||
Raises:
|
||||
InvalidOption if any combination of options is invalid
|
||||
InvalidOption.message will be descriptive message of invalid options
|
||||
"""
|
||||
if not any([exclusive, inclusive, dependent]):
|
||||
return True
|
||||
|
||||
prefix = "--" if cli else ""
|
||||
if exclusive:
|
||||
for a, b in exclusive:
|
||||
vala = getattr(self, a)
|
||||
valb = getattr(self, b)
|
||||
vala = any(vala) if isinstance(vala, tuple) else vala
|
||||
valb = any(valb) if isinstance(valb, tuple) else valb
|
||||
if vala and valb:
|
||||
stra = a.replace("_", "-") if cli else a
|
||||
strb = b.replace("_", "-") if cli else b
|
||||
raise ConfigOptionsInvalidError(
|
||||
f"{prefix}{stra} and {prefix}{strb} options cannot be used together."
|
||||
)
|
||||
if inclusive:
|
||||
for a, b in inclusive:
|
||||
vala = getattr(self, a)
|
||||
valb = getattr(self, b)
|
||||
vala = any(vala) if isinstance(vala, tuple) else vala
|
||||
valb = any(valb) if isinstance(valb, tuple) else valb
|
||||
if any([vala, valb]) and not all([vala, valb]):
|
||||
stra = a.replace("_", "-") if cli else a
|
||||
strb = b.replace("_", "-") if cli else b
|
||||
raise ConfigOptionsInvalidError(
|
||||
f"{prefix}{stra} and {prefix}{strb} options must be used together."
|
||||
)
|
||||
if dependent:
|
||||
for a, b in dependent:
|
||||
vala = getattr(self, a)
|
||||
if not isinstance(b, tuple):
|
||||
# python unrolls the tuple if there's a single element
|
||||
b = (b,)
|
||||
valb = [getattr(self, x) for x in b]
|
||||
valb = [any(x) if isinstance(x, tuple) else x for x in valb]
|
||||
if vala and not any(valb):
|
||||
if cli:
|
||||
stra = prefix + a.replace("_", "-")
|
||||
strb = ", ".join(prefix + x.replace("_", "-") for x in b)
|
||||
else:
|
||||
stra = a
|
||||
strb = ", ".join(b)
|
||||
raise ConfigOptionsInvalidError(
|
||||
f"{stra} must be used with at least one of: {strb}."
|
||||
)
|
||||
return True
|
||||
|
||||
def write_to_file(self, filename):
|
||||
""" Write self to TOML file
|
||||
|
||||
Args:
|
||||
filename: full path to TOML file to write; filename will be overwritten if it exists
|
||||
"""
|
||||
# todo: add overwrite and option to merge contents already in TOML file (under different [section] with new content)
|
||||
data = {}
|
||||
for attr in sorted(self._attrs.keys()):
|
||||
val = getattr(self, attr)
|
||||
if val in [False, ()]:
|
||||
val = None
|
||||
else:
|
||||
val = list(val) if type(val) == tuple else val
|
||||
|
||||
data[attr] = val
|
||||
|
||||
with open(filename, "w") as fd:
|
||||
toml.dump({self._name: data}, fd)
|
||||
|
||||
def load_from_file(self, filename, override=False):
|
||||
""" Load options from a TOML file.
|
||||
|
||||
Args:
|
||||
filename: full path to TOML file
|
||||
override: bool; if True, values in the TOML file will override values already set in the instance
|
||||
|
||||
Raises:
|
||||
ConfigOptionsLoadError if there are any errors during the parsing of the TOML file
|
||||
"""
|
||||
loaded = toml.load(filename)
|
||||
name = self._name
|
||||
if name not in loaded:
|
||||
raise ConfigOptionsLoadError(f"[{name}] section missing from {filename}")
|
||||
|
||||
for attr in loaded[name]:
|
||||
if attr not in self._attrs:
|
||||
raise ConfigOptionsLoadError(
|
||||
f"Unknown option: {attr} = {loaded[name][attr]}"
|
||||
)
|
||||
val = loaded[name][attr]
|
||||
if not override:
|
||||
# use value from self if set
|
||||
val = getattr(self, attr) or val
|
||||
if type(self._attrs[attr]) == tuple:
|
||||
val = tuple(val)
|
||||
setattr(self, attr, val)
|
||||
return self
|
||||
|
||||
def asdict(self):
|
||||
return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())}
|
||||
@@ -1,10 +1,10 @@
|
||||
""" datetime utilities """
|
||||
""" datetime.datetime helper functions for converting to/from UTC """
|
||||
|
||||
import datetime
|
||||
|
||||
|
||||
def get_local_tz(dt):
|
||||
""" return local timezone as datetime.timezone tzinfo for dt
|
||||
""" Return local timezone as datetime.timezone tzinfo for dt
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime
|
||||
@@ -21,21 +21,18 @@ def get_local_tz(dt):
|
||||
raise ValueError("dt must be naive datetime.datetime object")
|
||||
|
||||
|
||||
def datetime_remove_tz(dt):
|
||||
""" remove timezone from a datetime.datetime object
|
||||
dt: datetime.datetime object with tzinfo
|
||||
returns: dt without any timezone info (naive datetime object) """
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
return dt.replace(tzinfo=None)
|
||||
|
||||
|
||||
def datetime_has_tz(dt):
|
||||
""" return True if datetime dt has tzinfo else False
|
||||
""" Return True if datetime dt has tzinfo else False
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime
|
||||
returns True if dt is timezone aware, else False """
|
||||
|
||||
Returns:
|
||||
True if dt is timezone aware, else False
|
||||
|
||||
Raises:
|
||||
TypeError if dt is not a datetime.datetime object
|
||||
"""
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
@@ -43,11 +40,90 @@ def datetime_has_tz(dt):
|
||||
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
||||
|
||||
|
||||
def datetime_naive_to_local(dt):
|
||||
""" convert naive (timezone unaware) datetime.datetime
|
||||
to aware timezone in local timezone
|
||||
def datetime_tz_to_utc(dt):
|
||||
""" Convert datetime.datetime object with timezone to UTC timezone
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime object
|
||||
|
||||
Returns:
|
||||
datetime.datetime in UTC timezone
|
||||
|
||||
Raises:
|
||||
TypeError if dt is not datetime.datetime object
|
||||
ValueError if dt does not have timeone information
|
||||
"""
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||
return dt.replace(tzinfo=dt.tzinfo).astimezone(tz=datetime.timezone.utc)
|
||||
else:
|
||||
raise ValueError(f"dt does not have timezone info")
|
||||
|
||||
|
||||
def datetime_remove_tz(dt):
|
||||
""" Remove timezone from a datetime.datetime object
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime object with tzinfo
|
||||
|
||||
Returns:
|
||||
dt without any timezone info (naive datetime object)
|
||||
|
||||
Raises:
|
||||
TypeError if dt is not a datetime.datetime object
|
||||
"""
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
return dt.replace(tzinfo=None)
|
||||
|
||||
|
||||
def datetime_naive_to_utc(dt):
|
||||
""" Convert naive (timezone unaware) datetime.datetime
|
||||
to aware timezone in UTC timezone
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime without timezone
|
||||
returns: datetime.datetime with local timezone """
|
||||
|
||||
Returns:
|
||||
datetime.datetime with UTC timezone
|
||||
|
||||
Raises:
|
||||
TypeError if dt is not a datetime.datetime object
|
||||
ValueError if dt is not a naive/timezone unaware object
|
||||
"""
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
|
||||
# has timezone info
|
||||
raise ValueError(
|
||||
"dt must be naive/timezone unaware: "
|
||||
f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tzinfo.utcoffset(dt)}"
|
||||
)
|
||||
|
||||
return dt.replace(tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
||||
def datetime_naive_to_local(dt):
|
||||
""" Convert naive (timezone unaware) datetime.datetime
|
||||
to aware timezone in local timezone
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime without timezone
|
||||
|
||||
Returns:
|
||||
datetime.datetime with local timezone
|
||||
|
||||
Raises:
|
||||
TypeError if dt is not a datetime.datetime object
|
||||
ValueError if dt is not a naive/timezone unaware object
|
||||
"""
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
@@ -60,3 +136,26 @@ def datetime_naive_to_local(dt):
|
||||
)
|
||||
|
||||
return dt.replace(tzinfo=get_local_tz(dt))
|
||||
|
||||
|
||||
def datetime_utc_to_local(dt):
|
||||
""" Convert datetime.datetime object in UTC timezone to local timezone
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime object
|
||||
|
||||
Returns:
|
||||
datetime.datetime in local timezone
|
||||
|
||||
Raises:
|
||||
TypeError if dt is not a datetime.datetime object
|
||||
ValueError if dt is not in UTC timezone
|
||||
"""
|
||||
|
||||
if type(dt) != datetime.datetime:
|
||||
raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
|
||||
|
||||
if dt.tzinfo is not datetime.timezone.utc:
|
||||
raise ValueError(f"{dt} must be in UTC timezone: timezone = {dt.tzinfo}")
|
||||
|
||||
return dt.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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
|
||||
3. When used as a context manager, I wanted the operations to batch until exiting the context (improved performance)
|
||||
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 """
|
||||
|
||||
@@ -10,10 +11,8 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache # pylint: disable=syntax-error
|
||||
|
||||
from .utils import _debug
|
||||
|
||||
# exiftool -stay_open commands outputs this EOF marker after command is run
|
||||
EXIFTOOL_STAYOPEN_EOF = "{ready}"
|
||||
@@ -23,9 +22,7 @@ EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
|
||||
@lru_cache(maxsize=1)
|
||||
def get_exiftool_path():
|
||||
""" return path of exiftool, cache result """
|
||||
exiftool_path = shutil.which('exiftool')
|
||||
if _debug():
|
||||
logging.debug("exiftool path = %s" % (exiftool_path))
|
||||
exiftool_path = shutil.which("exiftool")
|
||||
if exiftool_path:
|
||||
return exiftool_path.rstrip()
|
||||
else:
|
||||
@@ -36,8 +33,8 @@ def get_exiftool_path():
|
||||
|
||||
|
||||
class _ExifToolProc:
|
||||
""" Runs exiftool in a subprocess via Popen
|
||||
Creates a singleton object """
|
||||
"""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 """
|
||||
@@ -47,20 +44,20 @@ class _ExifToolProc:
|
||||
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) """
|
||||
"""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:
|
||||
if exiftool is not None and exiftool != self._exiftool:
|
||||
logging.warning(
|
||||
f"exiftool subprocess already running, "
|
||||
f"ignoring exiftool={exiftool}"
|
||||
)
|
||||
return
|
||||
|
||||
self._exiftool = exiftool if exiftool else get_exiftool_path()
|
||||
self._process_running = False
|
||||
self._exiftool = exiftool or get_exiftool_path()
|
||||
self._start_proc()
|
||||
|
||||
@property
|
||||
@@ -98,18 +95,19 @@ class _ExifToolProc:
|
||||
"-", # read from stdin
|
||||
"-common_args", # specifies args common to all commands subsequently run
|
||||
"-n", # no print conversion (e.g. print tag values in machine readable format)
|
||||
"-P", # Preserve file modification date/time
|
||||
"-G", # print group name for each tag
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
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")
|
||||
@@ -134,39 +132,79 @@ class _ExifToolProc:
|
||||
class ExifTool:
|
||||
""" Basic exiftool interface for reading and writing EXIF tags """
|
||||
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True):
|
||||
""" Return ExifTool object
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True, flags=None):
|
||||
"""Create ExifTool object
|
||||
|
||||
Args:
|
||||
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 """
|
||||
overwrite: if True, will overwrite image file without creating backup, default=False
|
||||
flags: optional list of exiftool flags to prepend to exiftool command when writing metadata (e.g. -m or -F)
|
||||
|
||||
Returns:
|
||||
ExifTool instance
|
||||
"""
|
||||
self.file = filepath
|
||||
self.overwrite = overwrite
|
||||
self.flags = flags or []
|
||||
self.data = {}
|
||||
self.warning = None
|
||||
self.error = None
|
||||
# if running as a context manager, self._context_mgr will be True
|
||||
self._context_mgr = False
|
||||
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 """
|
||||
"""Set tag to value(s); if value is None, will delete tag
|
||||
|
||||
Args:
|
||||
tag: str; name of tag to set
|
||||
value: str; value to set tag to
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
value = ""
|
||||
command = [f"-{tag}={value}"]
|
||||
if self.overwrite:
|
||||
if self.overwrite and not self._context_mgr:
|
||||
command.append("-overwrite_original")
|
||||
self.run_commands(*command)
|
||||
if self._context_mgr:
|
||||
self._commands.extend(command)
|
||||
return True
|
||||
else:
|
||||
_, _, error = self.run_commands(*command)
|
||||
return error is None
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
""" Add one or more value(s) to tag
|
||||
"""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
|
||||
|
||||
Args:
|
||||
tag: str; tag to set
|
||||
*values: str; one or more values to set
|
||||
|
||||
Returns:
|
||||
True if success otherwise False
|
||||
|
||||
If error generated by exiftool, returns False and sets self.error to error string
|
||||
If warning generated by exiftool, returns True (unless there was also an error) and sets self.warning to warning string
|
||||
If called in context manager, returns True (execution is delayed until exiting context manager)
|
||||
|
||||
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")
|
||||
@@ -177,25 +215,51 @@ class ExifTool:
|
||||
raise ValueError("Can't add None value to tag")
|
||||
command.append(f"-{tag}+={value}")
|
||||
|
||||
if self.overwrite:
|
||||
if self.overwrite and not self._context_mgr:
|
||||
command.append("-overwrite_original")
|
||||
|
||||
if command:
|
||||
self.run_commands(*command)
|
||||
if self._context_mgr:
|
||||
self._commands.extend(command)
|
||||
return True
|
||||
else:
|
||||
_, _, error = self.run_commands(*command)
|
||||
return error is None
|
||||
|
||||
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 """
|
||||
"""Run commands in the exiftool process and return result.
|
||||
|
||||
Args:
|
||||
*commands: exiftool commands to run
|
||||
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
|
||||
Returns:
|
||||
(output, warning, errror)
|
||||
output: bytes is containing output of exiftool commands
|
||||
warning: if exiftool generated warnings, string containing warning otherwise empty string
|
||||
error: if exiftool generated errors, string containing otherwise empty string
|
||||
|
||||
Note: Also sets self.warning and self.error if warning or error generated.
|
||||
"""
|
||||
if not (hasattr(self, "_process") and self._process):
|
||||
raise ValueError("exiftool process is not running")
|
||||
|
||||
if not commands:
|
||||
raise TypeError("must provide one or more command to run")
|
||||
|
||||
if self._context_mgr and self.overwrite:
|
||||
commands = list(commands)
|
||||
commands.append("-overwrite_original")
|
||||
|
||||
filename = os.fsencode(self.file) if not no_file else b""
|
||||
command_str = (
|
||||
|
||||
if self.flags:
|
||||
command_str = b"\n".join([f.encode("utf-8") for f in self.flags])
|
||||
command_str += b"\n"
|
||||
else:
|
||||
command_str = b""
|
||||
|
||||
command_str += (
|
||||
b"\n".join([c.encode("utf-8") for c in commands])
|
||||
+ b"\n"
|
||||
+ filename
|
||||
@@ -203,18 +267,27 @@ class ExifTool:
|
||||
+ 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""
|
||||
warning = b""
|
||||
error = b""
|
||||
while EXIFTOOL_STAYOPEN_EOF not in str(output):
|
||||
output += self._process.stdout.readline().strip()
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN]
|
||||
line = self._process.stdout.readline()
|
||||
if line.startswith(b"Warning"):
|
||||
warning += line.strip()
|
||||
elif line.startswith(b"Error"):
|
||||
error += line.strip()
|
||||
else:
|
||||
output += line.strip()
|
||||
warning = "" if warning == b"" else warning.decode("utf-8")
|
||||
error = "" if error == b"" else error.decode("utf-8")
|
||||
self.warning = warning
|
||||
self.error = error
|
||||
return output[:-EXIFTOOL_STAYOPEN_EOF_LEN], warning, error
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
@@ -224,14 +297,14 @@ class ExifTool:
|
||||
@property
|
||||
def version(self):
|
||||
""" returns exiftool version """
|
||||
ver = self.run_commands("-ver", no_file=True)
|
||||
ver, _, _ = self.run_commands("-ver", no_file=True)
|
||||
return ver.decode("utf-8")
|
||||
|
||||
def as_dict(self):
|
||||
""" return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
def asdict(self):
|
||||
"""return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
"""
|
||||
json_str = self.run_commands("-json")
|
||||
json_str, _, _ = self.run_commands("-json")
|
||||
if json_str:
|
||||
exifdict = json.loads(json_str)
|
||||
return exifdict[0]
|
||||
@@ -240,12 +313,25 @@ class ExifTool:
|
||||
|
||||
def json(self):
|
||||
""" returns JSON string containing all EXIF tags and values from exiftool """
|
||||
return self.run_commands("-json")
|
||||
json, _, _ = self.run_commands("-json")
|
||||
return json
|
||||
|
||||
def _read_exif(self):
|
||||
""" read exif data from file """
|
||||
data = self.as_dict()
|
||||
data = self.asdict()
|
||||
self.data = {k: v for k, v in data.items()}
|
||||
|
||||
def __str__(self):
|
||||
return f"file: {self.file}\nexiftool: {self._exiftoolproc._exiftool}"
|
||||
|
||||
def __enter__(self):
|
||||
self._context_mgr = True
|
||||
self._commands = []
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
if exc_type:
|
||||
return False
|
||||
elif self._commands:
|
||||
# run_commands sets self.warning and self.error as needed
|
||||
self.run_commands(*self._commands)
|
||||
|
||||
@@ -14,7 +14,7 @@ from sqlite3 import Error
|
||||
|
||||
from ._version import __version__
|
||||
|
||||
OSXPHOTOS_EXPORTDB_VERSION = "1.0"
|
||||
OSXPHOTOS_EXPORTDB_VERSION = "3.2"
|
||||
|
||||
|
||||
class ExportDB_ABC(ABC):
|
||||
@@ -36,6 +36,22 @@ class ExportDB_ABC(ABC):
|
||||
def get_stat_orig_for_file(self, filename):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_stat_edited_for_file(self, filename, stats):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_stat_edited_for_file(self, filename):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_stat_converted_for_file(self, filename, stats):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_stat_converted_for_file(self, filename):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_stat_exif_for_file(self, filename, stats):
|
||||
pass
|
||||
@@ -61,13 +77,36 @@ class ExportDB_ABC(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_sidecar_for_file(self, filename):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_data(
|
||||
self,
|
||||
filename,
|
||||
uuid,
|
||||
orig_stat,
|
||||
exif_stat,
|
||||
converted_stat,
|
||||
edited_stat,
|
||||
info_json,
|
||||
exif_json,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class ExportDBNoOp(ExportDB_ABC):
|
||||
""" An ExportDB with NoOp methods """
|
||||
|
||||
def __init__(self):
|
||||
self.was_created = True
|
||||
self.was_upgraded = False
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
|
||||
def get_uuid_for_file(self, filename):
|
||||
pass
|
||||
|
||||
@@ -80,6 +119,18 @@ class ExportDBNoOp(ExportDB_ABC):
|
||||
def get_stat_orig_for_file(self, filename):
|
||||
pass
|
||||
|
||||
def set_stat_edited_for_file(self, filename, stats):
|
||||
pass
|
||||
|
||||
def get_stat_edited_for_file(self, filename):
|
||||
pass
|
||||
|
||||
def set_stat_converted_for_file(self, filename, stats):
|
||||
pass
|
||||
|
||||
def get_stat_converted_for_file(self, filename):
|
||||
pass
|
||||
|
||||
def set_stat_exif_for_file(self, filename, stats):
|
||||
pass
|
||||
|
||||
@@ -98,7 +149,23 @@ class ExportDBNoOp(ExportDB_ABC):
|
||||
def set_exifdata_for_file(self, uuid, exifdata):
|
||||
pass
|
||||
|
||||
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
|
||||
pass
|
||||
|
||||
def get_sidecar_for_file(self, filename):
|
||||
return None, (None, None, None)
|
||||
|
||||
def set_data(
|
||||
self,
|
||||
filename,
|
||||
uuid,
|
||||
orig_stat,
|
||||
exif_stat,
|
||||
converted_stat,
|
||||
edited_stat,
|
||||
info_json,
|
||||
exif_json,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@@ -122,7 +189,6 @@ class ExportDB(ExportDB_ABC):
|
||||
returns None if filename not found in database
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
logging.debug(f"get_uuid: {filename}")
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
@@ -135,14 +201,12 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
uuid = None
|
||||
|
||||
logging.debug(f"get_uuid: {uuid}")
|
||||
return uuid
|
||||
|
||||
def set_uuid_for_file(self, filename, uuid):
|
||||
""" set UUID of filename to uuid in the database """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path))
|
||||
filename_normalized = filename.lower()
|
||||
logging.debug(f"set_uuid: {filename} {uuid}")
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
@@ -162,7 +226,6 @@ class ExportDB(ExportDB_ABC):
|
||||
if len(stats) != 3:
|
||||
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
|
||||
|
||||
logging.debug(f"set_stat_orig_for_file: {filename} {stats}")
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
@@ -199,9 +262,20 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
|
||||
logging.debug(f"get_stat_orig_for_file: {stats}")
|
||||
return stats
|
||||
|
||||
def set_stat_edited_for_file(self, filename, stats):
|
||||
""" set stat info for edited version of image (in Photos' library)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
return self._set_stat_for_file("edited", filename, stats)
|
||||
|
||||
def get_stat_edited_for_file(self, filename):
|
||||
""" get stat info for edited version of image (in Photos' library)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
return self._get_stat_for_file("edited", filename)
|
||||
|
||||
def set_stat_exif_for_file(self, filename, stats):
|
||||
""" set stat info for filename (after exiftool has updated it)
|
||||
filename: filename to set the stat info for
|
||||
@@ -210,7 +284,6 @@ class ExportDB(ExportDB_ABC):
|
||||
if len(stats) != 3:
|
||||
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
|
||||
|
||||
logging.debug(f"set_stat_exif_for_file: {filename} {stats}")
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
@@ -247,9 +320,20 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
|
||||
logging.debug(f"get_stat_exif_for_file: {stats}")
|
||||
return stats
|
||||
|
||||
def set_stat_converted_for_file(self, filename, stats):
|
||||
""" set stat info for filename (after image converted to jpeg)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
return self._set_stat_for_file("converted", filename, stats)
|
||||
|
||||
def get_stat_converted_for_file(self, filename):
|
||||
""" get stat info for filename (after jpeg conversion)
|
||||
returns: tuple of (mode, size, mtime)
|
||||
"""
|
||||
return self._get_stat_for_file("converted", filename)
|
||||
|
||||
def get_info_for_uuid(self, uuid):
|
||||
""" returns the info JSON struct for a UUID """
|
||||
conn = self._conn
|
||||
@@ -262,7 +346,6 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
info = None
|
||||
|
||||
logging.debug(f"get_info: {uuid}, {info}")
|
||||
return info
|
||||
|
||||
def set_info_for_uuid(self, uuid, info):
|
||||
@@ -278,8 +361,6 @@ class ExportDB(ExportDB_ABC):
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
logging.debug(f"set_info: {uuid}, {info}")
|
||||
|
||||
def get_exifdata_for_file(self, filename):
|
||||
""" returns the exifdata JSON struct for a file """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
@@ -296,7 +377,6 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
exifdata = None
|
||||
|
||||
logging.debug(f"get_exifdata: {filename}, {exifdata}")
|
||||
return exifdata
|
||||
|
||||
def set_exifdata_for_file(self, filename, exifdata):
|
||||
@@ -313,9 +393,59 @@ class ExportDB(ExportDB_ABC):
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
logging.debug(f"set_exifdata: {filename}, {exifdata}")
|
||||
def get_sidecar_for_file(self, filename):
|
||||
""" returns the sidecar data and signature for a file """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"SELECT sidecar_data, mode, size, mtime FROM sidecar WHERE filepath_normalized = ?",
|
||||
(filename,),
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
sidecar_data = results[0]
|
||||
sidecar_sig = (
|
||||
results[1],
|
||||
results[2],
|
||||
int(results[3]) if results[3] is not None else None,
|
||||
)
|
||||
else:
|
||||
sidecar_data = None
|
||||
sidecar_sig = (None, None, None)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
sidecar_data = None
|
||||
sidecar_sig = (None, None, None)
|
||||
|
||||
def set_data(self, filename, uuid, orig_stat, exif_stat, info_json, exif_json):
|
||||
return sidecar_data, sidecar_sig
|
||||
|
||||
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
|
||||
""" sets the sidecar data and signature for a file """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO sidecar(filepath_normalized, sidecar_data, mode, size, mtime) VALUES (?, ?, ?, ?, ?);",
|
||||
(filename, sidecar_data, *sidecar_sig),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def set_data(
|
||||
self,
|
||||
filename,
|
||||
uuid,
|
||||
orig_stat,
|
||||
exif_stat,
|
||||
converted_stat,
|
||||
edited_stat,
|
||||
info_json,
|
||||
exif_json,
|
||||
):
|
||||
""" sets all the data for file and uuid at once
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path))
|
||||
@@ -339,6 +469,14 @@ class ExportDB(ExportDB_ABC):
|
||||
+ "WHERE filepath_normalized = ?;",
|
||||
(*exif_stat, filename_normalized),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO converted(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
|
||||
(filename_normalized, *converted_stat),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO edited(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
|
||||
(filename_normalized, *edited_stat),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
|
||||
(uuid, info_json),
|
||||
@@ -358,6 +496,37 @@ class ExportDB(ExportDB_ABC):
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def _set_stat_for_file(self, table, filename, stats):
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
if len(stats) != 3:
|
||||
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
|
||||
|
||||
conn = self._conn
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT OR REPLACE INTO {table}(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
|
||||
(filename, *stats),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _get_stat_for_file(self, table, filename):
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"SELECT mode, size, mtime FROM {table} WHERE filepath_normalized = ?",
|
||||
(filename,),
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
stats = (None, None, None)
|
||||
|
||||
return stats
|
||||
|
||||
def _open_export_db(self, dbfile):
|
||||
""" open export database and return a db connection
|
||||
if dbfile does not exist, will create and initialize the database
|
||||
@@ -365,16 +534,22 @@ class ExportDB(ExportDB_ABC):
|
||||
"""
|
||||
|
||||
if not os.path.isfile(dbfile):
|
||||
logging.debug(f"dbfile {dbfile} doesn't exist, creating it")
|
||||
conn = self._get_db_connection(dbfile)
|
||||
if conn:
|
||||
self._create_db_tables(conn)
|
||||
else:
|
||||
if not conn:
|
||||
raise Exception("Error getting connection to database {dbfile}")
|
||||
self._create_db_tables(conn)
|
||||
self.was_created = True
|
||||
self.was_upgraded = ()
|
||||
else:
|
||||
logging.debug(f"dbfile {dbfile} exists, opening it")
|
||||
conn = self._get_db_connection(dbfile)
|
||||
|
||||
self.was_created = False
|
||||
version_info = self._get_database_version(conn)
|
||||
if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION:
|
||||
self._create_db_tables(conn)
|
||||
self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION)
|
||||
else:
|
||||
self.was_upgraded = ()
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
return conn
|
||||
|
||||
def _get_db_connection(self, dbfile):
|
||||
@@ -387,6 +562,13 @@ class ExportDB(ExportDB_ABC):
|
||||
|
||||
return conn
|
||||
|
||||
def _get_database_version(self, conn):
|
||||
""" return tuple of (osxphotos, exportdb) versions for database connection conn """
|
||||
version_info = conn.execute(
|
||||
"SELECT osxphotos, exportdb, max(id) FROM version"
|
||||
).fetchone()
|
||||
return (version_info[0], version_info[1])
|
||||
|
||||
def _create_db_tables(self, conn):
|
||||
""" create (if not already created) the necessary db tables for the export database
|
||||
conn: sqlite3 db connection
|
||||
@@ -427,9 +609,34 @@ class ExportDB(ExportDB_ABC):
|
||||
filepath_normalized TEXT NOT NULL,
|
||||
json_exifdata JSON
|
||||
); """,
|
||||
"sql_files_idx": """ CREATE UNIQUE INDEX idx_files_filepath_normalized on files (filepath_normalized); """,
|
||||
"sql_info_idx": """ CREATE UNIQUE INDEX idx_info_uuid on info (uuid); """,
|
||||
"sql_exifdata_idx": """ CREATE UNIQUE INDEX idx_exifdata_filename on exifdata (filepath_normalized); """,
|
||||
"sql_edited_table": """ CREATE TABLE IF NOT EXISTS edited (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filepath_normalized TEXT NOT NULL,
|
||||
mode INTEGER,
|
||||
size INTEGER,
|
||||
mtime REAL
|
||||
); """,
|
||||
"sql_converted_table": """ CREATE TABLE IF NOT EXISTS converted (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filepath_normalized TEXT NOT NULL,
|
||||
mode INTEGER,
|
||||
size INTEGER,
|
||||
mtime REAL
|
||||
); """,
|
||||
"sql_sidecar_table": """ CREATE TABLE IF NOT EXISTS sidecar (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filepath_normalized TEXT NOT NULL,
|
||||
sidecar_data TEXT,
|
||||
mode INTEGER,
|
||||
size INTEGER,
|
||||
mtime REAL
|
||||
); """,
|
||||
"sql_files_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """,
|
||||
"sql_info_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """,
|
||||
"sql_exifdata_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """,
|
||||
"sql_edited_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""",
|
||||
"sql_converted_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""",
|
||||
"sql_sidecar_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""",
|
||||
}
|
||||
try:
|
||||
c = conn.cursor()
|
||||
@@ -445,11 +652,10 @@ class ExportDB(ExportDB_ABC):
|
||||
|
||||
def __del__(self):
|
||||
""" ensure the database connection is closed """
|
||||
if self._conn:
|
||||
try:
|
||||
self._conn.close()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
try:
|
||||
self._conn.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
def _insert_run_info(self):
|
||||
dt = datetime.datetime.utcnow().isoformat()
|
||||
@@ -488,18 +694,18 @@ class ExportDBInMemory(ExportDB):
|
||||
|
||||
def _open_export_db(self, dbfile):
|
||||
""" open export database and return a db connection
|
||||
if dbfile does not exist, will create and initialize the database
|
||||
returns: connection to the database
|
||||
"""
|
||||
if not os.path.isfile(dbfile):
|
||||
logging.debug(f"dbfile {dbfile} doesn't exist, creating in memory version")
|
||||
conn = self._get_db_connection()
|
||||
if conn:
|
||||
self._create_db_tables(conn)
|
||||
self.was_created = True
|
||||
self.was_upgraded = ()
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
else:
|
||||
raise Exception("Error getting connection to in-memory database")
|
||||
else:
|
||||
logging.debug(f"dbfile {dbfile} exists, opening it and copying to memory")
|
||||
try:
|
||||
conn = sqlite3.connect(dbfile)
|
||||
except Error as e:
|
||||
@@ -516,6 +722,14 @@ class ExportDBInMemory(ExportDB):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.cursor().executescript(tempfile.read())
|
||||
conn.commit()
|
||||
self.was_created = False
|
||||
_, exportdb_ver = self._get_database_version(conn)
|
||||
if exportdb_ver < OSXPHOTOS_EXPORTDB_VERSION:
|
||||
self._create_db_tables(conn)
|
||||
self.was_upgraded = (exportdb_ver, OSXPHOTOS_EXPORTDB_VERSION)
|
||||
else:
|
||||
self.was_upgraded = ()
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
|
||||
return conn
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
""" FileUtil class with methods for copy, hardlink, unlink, etc. """
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
@@ -8,6 +7,10 @@ import subprocess
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import CoreFoundation
|
||||
|
||||
from .imageconverter import ImageConverter
|
||||
|
||||
|
||||
class FileUtilABC(ABC):
|
||||
""" Abstract base class for FileUtil """
|
||||
@@ -27,6 +30,11 @@ class FileUtilABC(ABC):
|
||||
def unlink(cls, dest):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def rmdir(cls, dest):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def utime(cls, path, times):
|
||||
@@ -47,6 +55,11 @@ class FileUtilABC(ABC):
|
||||
def file_sig(cls, file1):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
|
||||
pass
|
||||
|
||||
|
||||
class FileUtilMacOS(FileUtilABC):
|
||||
""" Various file utilities """
|
||||
@@ -68,42 +81,41 @@ class FileUtilMacOS(FileUtilABC):
|
||||
try:
|
||||
os.link(src, dest)
|
||||
except Exception as e:
|
||||
logging.critical(f"os.link returned error: {e}")
|
||||
raise e
|
||||
|
||||
@classmethod
|
||||
def copy(cls, src, dest, norsrc=False):
|
||||
def copy(cls, src, dest):
|
||||
""" Copies a file from src path to dest path
|
||||
src: source path as string
|
||||
|
||||
Args:
|
||||
src: source path as string; must be a valid file path
|
||||
dest: destination path as string
|
||||
norsrc: (bool) if True, uses --norsrc flag with ditto so it will not copy
|
||||
resource fork or extended attributes. May be useful on volumes that
|
||||
don't work with extended attributes (likely only certain SMB mounts)
|
||||
default is False
|
||||
Uses ditto to perform copy; will silently overwrite dest if it exists
|
||||
Raises exception if copy fails or either path is None """
|
||||
dest may be either directory or file; in either case, src file must not exist in dest
|
||||
Note: src and dest may be either a string or a pathlib.Path object
|
||||
|
||||
Returns:
|
||||
True if copy succeeded
|
||||
|
||||
Raises:
|
||||
OSError if copy fails
|
||||
TypeError if either path is None
|
||||
"""
|
||||
if not isinstance(src, pathlib.Path):
|
||||
src = pathlib.Path(src)
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
if not isinstance(dest, pathlib.Path):
|
||||
dest = pathlib.Path(dest)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
raise FileNotFoundError("src file does not appear to exist", src)
|
||||
if dest.is_dir():
|
||||
dest /= src.name
|
||||
|
||||
if norsrc:
|
||||
command = ["/usr/bin/ditto", "--norsrc", src, dest]
|
||||
else:
|
||||
command = ["/usr/bin/ditto", src, dest]
|
||||
|
||||
# if error on copy, subprocess will raise CalledProcessError
|
||||
try:
|
||||
result = subprocess.run(command, check=True, stderr=subprocess.PIPE)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.critical(
|
||||
f"ditto returned error: {e.returncode} {e.stderr.decode(sys.getfilesystemencoding()).rstrip()}"
|
||||
)
|
||||
raise e
|
||||
|
||||
return result.returncode
|
||||
filemgr = CoreFoundation.NSFileManager.defaultManager()
|
||||
error = filemgr.copyItemAtPath_toPath_error_(str(src), str(dest), None)
|
||||
# error is a tuple of (bool, error_string)
|
||||
# error[0] is True if copy succeeded
|
||||
if not error[0]:
|
||||
raise OSError(error[1])
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def unlink(cls, filepath):
|
||||
@@ -113,6 +125,14 @@ class FileUtilMacOS(FileUtilABC):
|
||||
else:
|
||||
os.unlink(filepath)
|
||||
|
||||
@classmethod
|
||||
def rmdir(cls, dirpath):
|
||||
""" remove directory filepath; dirpath must be empty """
|
||||
if isinstance(dirpath, pathlib.Path):
|
||||
dirpath.rmdir()
|
||||
else:
|
||||
os.rmdir(dirpath)
|
||||
|
||||
@classmethod
|
||||
def utime(cls, path, times):
|
||||
""" Set the access and modified time of path. """
|
||||
@@ -164,6 +184,23 @@ class FileUtilMacOS(FileUtilABC):
|
||||
""" return os.stat signature for file f1 """
|
||||
return cls._sig(os.stat(f1))
|
||||
|
||||
@classmethod
|
||||
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
|
||||
""" converts image file src_file to jpeg format as dest_file
|
||||
|
||||
Args:
|
||||
src_file: image file to convert
|
||||
dest_file: destination path to write converted file to
|
||||
compression quality: JPEG compression quality in range 0.0 <= compression_quality <= 1.0; default 1.0 (best quality)
|
||||
|
||||
Returns:
|
||||
True if success, otherwise False
|
||||
"""
|
||||
converter = ImageConverter()
|
||||
return converter.write_jpeg(
|
||||
src_file, dest_file, compression_quality=compression_quality
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sig(st):
|
||||
""" return tuple of (mode, size, mtime) of file based on os.stat
|
||||
@@ -213,6 +250,10 @@ class FileUtilNoOp(FileUtil):
|
||||
def unlink(cls, dest):
|
||||
cls.verbose(f"unlink: {dest}")
|
||||
|
||||
@classmethod
|
||||
def rmdir(cls, dest):
|
||||
cls.verbose(f"rmdir: {dest}")
|
||||
|
||||
@classmethod
|
||||
def utime(cls, path, times):
|
||||
cls.verbose(f"utime: {path}, {times}")
|
||||
@@ -221,3 +262,7 @@ class FileUtilNoOp(FileUtil):
|
||||
def file_sig(cls, file1):
|
||||
cls.verbose(f"file_sig: {file1}")
|
||||
return (42, 42, 42)
|
||||
|
||||
@classmethod
|
||||
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
|
||||
cls.verbose(f"convert_to_jpeg: {src_file}, {dest_file}, {compression_quality}")
|
||||
|
||||
112
osxphotos/imageconverter.py
Normal file
@@ -0,0 +1,112 @@
|
||||
""" ImageConverter class
|
||||
Convert an image to JPEG using CoreImage --
|
||||
for example, RAW to JPEG. Only works if Mac equipped with GPU. """
|
||||
|
||||
# reference: https://stackoverflow.com/questions/59330149/coreimage-ciimage-write-jpg-is-shifting-colors-macos/59334308#59334308
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
import Metal
|
||||
import Quartz
|
||||
from Cocoa import NSURL
|
||||
from Foundation import NSDictionary
|
||||
|
||||
# needed to capture system-level stderr
|
||||
from wurlitzer import pipes
|
||||
|
||||
|
||||
class ImageConverter:
|
||||
""" Convert images to jpeg. This class is a singleton
|
||||
which will re-use the Core Image CIContext to avoid
|
||||
creating a new context for every conversion. """
|
||||
|
||||
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):
|
||||
""" return existing singleton or create a new one """
|
||||
|
||||
if hasattr(self, "context"):
|
||||
return
|
||||
|
||||
""" initialize CIContext """
|
||||
context_options = NSDictionary.dictionaryWithDictionary_(
|
||||
{
|
||||
"workingColorSpace": Quartz.CoreGraphics.kCGColorSpaceExtendedSRGB,
|
||||
"workingFormat": Quartz.kCIFormatRGBAh,
|
||||
}
|
||||
)
|
||||
mtldevice = Metal.MTLCreateSystemDefaultDevice()
|
||||
self.context = Quartz.CIContext.contextWithMTLDevice_options_(
|
||||
mtldevice, context_options
|
||||
)
|
||||
|
||||
def write_jpeg(self, input_path, output_path, compression_quality=1.0):
|
||||
""" convert image to jpeg and write image to output_path
|
||||
|
||||
Args:
|
||||
input_path: path to input image (e.g. '/path/to/import/file.CR2') as str or pathlib.Path
|
||||
output_path: path to exported jpeg (e.g. '/path/to/export/file.jpeg') as str or pathlib.Path
|
||||
compression_quality: JPEG compression quality, float in range 0.0 to 1.0; default is 1.0 (best quality)
|
||||
|
||||
Return:
|
||||
True if conversion successful, else False
|
||||
|
||||
Raises:
|
||||
ValueError if compression quality not in range 0.0 to 1.0
|
||||
FileNotFoundError if input_path doesn't exist
|
||||
"""
|
||||
|
||||
# accept input_path or output_path as pathlib.Path
|
||||
if not isinstance(input_path, str):
|
||||
input_path = str(input_path)
|
||||
|
||||
if not isinstance(output_path, str):
|
||||
output_path = str(output_path)
|
||||
|
||||
if not pathlib.Path(input_path).is_file():
|
||||
raise FileNotFoundError(f"could not find {input_path}")
|
||||
|
||||
if not (0.0 <= compression_quality <= 1.0):
|
||||
raise ValueError(
|
||||
"illegal value for compression_quality: {compression_quality}"
|
||||
)
|
||||
|
||||
input_url = NSURL.fileURLWithPath_(input_path)
|
||||
output_url = NSURL.fileURLWithPath_(output_path)
|
||||
|
||||
with pipes() as (out, err):
|
||||
# capture stdout and stderr from system calls
|
||||
# otherwise, Quartz.CIImage.imageWithContentsOfURL_
|
||||
# prints to stderr something like:
|
||||
# 2020-09-20 20:55:25.538 python[73042:5650492] Creating client/daemon connection: B8FE995E-3F27-47F4-9FA8-559C615FD774
|
||||
# 2020-09-20 20:55:25.652 python[73042:5650492] Got the query meta data reply for: com.apple.MobileAsset.RawCamera.Camera, response: 0
|
||||
input_image = Quartz.CIImage.imageWithContentsOfURL_(input_url)
|
||||
|
||||
if input_image is None:
|
||||
logging.debug(f"Could not create CIImage for {input_path}")
|
||||
return False
|
||||
|
||||
output_colorspace = input_image.colorSpace() or Quartz.CGColorSpaceCreateWithName(
|
||||
Quartz.CoreGraphics.kCGColorSpaceSRGB
|
||||
)
|
||||
|
||||
output_options = NSDictionary.dictionaryWithDictionary_(
|
||||
{"kCGImageDestinationLossyCompressionQuality": compression_quality}
|
||||
)
|
||||
_, error = self.context.writeJPEGRepresentationOfImage_toURL_colorSpace_options_error_(
|
||||
input_image, output_url, output_colorspace, output_options, None
|
||||
)
|
||||
if not error:
|
||||
return True
|
||||
else:
|
||||
logging.debug(
|
||||
"Error converting file {input_path} to jpeg at {output_path}: {error}"
|
||||
)
|
||||
return False
|
||||
|
||||
79
osxphotos/path_utils.py
Normal file
@@ -0,0 +1,79 @@
|
||||
""" utility functions for validating/sanitizing path components """
|
||||
|
||||
import pathvalidate
|
||||
|
||||
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
|
||||
|
||||
|
||||
def sanitize_filepath(filepath):
|
||||
""" sanitize a filepath """
|
||||
return pathvalidate.sanitize_filepath(filepath, platform="macos")
|
||||
|
||||
|
||||
def is_valid_filepath(filepath):
|
||||
""" returns True if a filepath is valid otherwise False """
|
||||
return pathvalidate.is_valid_filepath(filepath, platform="macos")
|
||||
|
||||
|
||||
def sanitize_filename(filename, replacement=":"):
|
||||
""" replace any illegal characters in a filename and truncate filename if needed
|
||||
|
||||
Args:
|
||||
filename: str, filename to sanitze
|
||||
replacement: str, value to replace any illegal characters with; default = ":"
|
||||
|
||||
Returns:
|
||||
filename with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
|
||||
if filename:
|
||||
filename = filename.replace("/", replacement)
|
||||
if len(filename) > MAX_FILENAME_LEN:
|
||||
parts = filename.split(".")
|
||||
drop = len(filename) - MAX_FILENAME_LEN
|
||||
if len(parts) > 1:
|
||||
# has an extension
|
||||
ext = parts.pop(-1)
|
||||
stem = ".".join(parts)
|
||||
if drop > len(stem):
|
||||
ext = ext[:-drop]
|
||||
else:
|
||||
stem = stem[:-drop]
|
||||
filename = f"{stem}.{ext}"
|
||||
else:
|
||||
filename = filename[:-drop]
|
||||
return filename
|
||||
|
||||
|
||||
def sanitize_dirname(dirname, replacement=":"):
|
||||
""" replace any illegal characters in a directory name and truncate directory name if needed
|
||||
|
||||
Args:
|
||||
dirname: str, directory name to sanitze
|
||||
replacement: str, value to replace any illegal characters with; default = ":"
|
||||
|
||||
Returns:
|
||||
dirname with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
if dirname:
|
||||
dirname = sanitize_pathpart(dirname, replacement=replacement)
|
||||
return dirname
|
||||
|
||||
|
||||
def sanitize_pathpart(pathpart, replacement=":"):
|
||||
""" replace any illegal characters in a path part (either directory or filename without extension) and truncate name if needed
|
||||
|
||||
Args:
|
||||
pathpart: str, path part to sanitze
|
||||
replacement: str, value to replace any illegal characters with; default = ":"
|
||||
|
||||
Returns:
|
||||
pathpart with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
if pathpart:
|
||||
pathpart = pathpart.replace("/", replacement)
|
||||
if len(pathpart) > MAX_DIRNAME_LEN:
|
||||
drop = len(pathpart) - MAX_DIRNAME_LEN
|
||||
pathpart = pathpart[:-drop]
|
||||
return pathpart
|
||||
|
||||
@@ -66,10 +66,10 @@ class PersonInfo:
|
||||
# no faces
|
||||
return []
|
||||
|
||||
def json(self):
|
||||
""" Returns JSON representation of class instance """
|
||||
def asdict(self):
|
||||
""" Returns dictionary representation of class instance """
|
||||
keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None
|
||||
person = {
|
||||
return {
|
||||
"uuid": self.uuid,
|
||||
"name": self.name,
|
||||
"displayname": self.display_name,
|
||||
@@ -77,7 +77,10 @@ class PersonInfo:
|
||||
"facecount": self.facecount,
|
||||
"keyphoto": keyphoto,
|
||||
}
|
||||
return json.dumps(person)
|
||||
|
||||
def json(self):
|
||||
""" Returns JSON representation of class instance """
|
||||
return json.dumps(self.asdict())
|
||||
|
||||
def __str__(self):
|
||||
return f"PersonInfo(name={self.name}, display_name={self.display_name}, uuid={self.uuid}, facecount={self.facecount})"
|
||||
|
||||
17
osxphotos/photoinfo/_photoinfo_comments.py
Normal file
@@ -0,0 +1,17 @@
|
||||
""" PhotoInfo methods to expose comments and likes for shared photos """
|
||||
|
||||
@property
|
||||
def comments(self):
|
||||
""" Returns list of Comment objects for any comments on the photo (sorted by date) """
|
||||
try:
|
||||
return self._db._db_comments_uuid[self.uuid]["comments"]
|
||||
except:
|
||||
return []
|
||||
|
||||
@property
|
||||
def likes(self):
|
||||
""" Returns list of Like objects for any likes on the photo (sorted by date) """
|
||||
try:
|
||||
return self._db._db_comments_uuid[self.uuid]["likes"]
|
||||
except:
|
||||
return []
|
||||
@@ -18,12 +18,11 @@ def exiftool(self):
|
||||
return self._exiftool
|
||||
except AttributeError:
|
||||
try:
|
||||
exiftool_path = get_exiftool_path()
|
||||
exiftool_path = self._db._exiftool_path or get_exiftool_path()
|
||||
if self.path is not None and os.path.isfile(self.path):
|
||||
exiftool = ExifTool(self.path)
|
||||
exiftool = ExifTool(self.path, exiftool=exiftool_path)
|
||||
else:
|
||||
exiftool = None
|
||||
logging.debug(f"exiftool: missing path {self.uuid}")
|
||||
except FileNotFoundError:
|
||||
# get_exiftool_path raises FileNotFoundError if exiftool not found
|
||||
exiftool = None
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
|
||||
Adds the following properties to PhotoInfo (valid only for Photos 5):
|
||||
search_info: returns a SearchInfo object
|
||||
search_info_normalized: returns a SearchInfo object with properties that produce normalized results
|
||||
labels: returns list of labels
|
||||
labels_normalized: returns list of normalized labels
|
||||
"""
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
|
||||
from .._constants import (
|
||||
_PHOTOS_4_VERSION,
|
||||
SEARCH_CATEGORY_CITY,
|
||||
SEARCH_CATEGORY_LABEL,
|
||||
SEARCH_CATEGORY_NEIGHBORHOOD,
|
||||
SEARCH_CATEGORY_PLACE_NAME,
|
||||
SEARCH_CATEGORY_STREET,
|
||||
SEARCH_CATEGORY_ALL_LOCALITY,
|
||||
SEARCH_CATEGORY_COUNTRY,
|
||||
SEARCH_CATEGORY_STATE,
|
||||
SEARCH_CATEGORY_STATE_ABBREVIATION,
|
||||
SEARCH_CATEGORY_BODY_OF_WATER,
|
||||
SEARCH_CATEGORY_MONTH,
|
||||
SEARCH_CATEGORY_YEAR,
|
||||
SEARCH_CATEGORY_HOLIDAY,
|
||||
SEARCH_CATEGORY_ACTIVITY,
|
||||
SEARCH_CATEGORY_SEASON,
|
||||
SEARCH_CATEGORY_VENUE,
|
||||
SEARCH_CATEGORY_VENUE_TYPE,
|
||||
SEARCH_CATEGORY_MEDIA_TYPES,
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
@@ -24,6 +45,22 @@ def search_info(self):
|
||||
return self._search_info
|
||||
|
||||
|
||||
@property
|
||||
def search_info_normalized(self):
|
||||
""" returns SearchInfo object for photo that produces normalized results
|
||||
only valid on Photos 5, on older libraries, returns None
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
# memoize SearchInfo object
|
||||
try:
|
||||
return self._search_info_normalized
|
||||
except AttributeError:
|
||||
self._search_info_normalized = SearchInfo(self, normalized=True)
|
||||
return self._search_info_normalized
|
||||
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" returns list of labels applied to photo by Photos image categorization
|
||||
@@ -43,14 +80,15 @@ def labels_normalized(self):
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info.labels_normalized
|
||||
return self.search_info_normalized.labels
|
||||
|
||||
|
||||
class SearchInfo:
|
||||
""" Info about search terms such as machine learning labels that Photos knows about a photo """
|
||||
|
||||
def __init__(self, photo):
|
||||
""" photo: PhotoInfo object """
|
||||
def __init__(self, photo, normalized=False):
|
||||
""" photo: PhotoInfo object
|
||||
normalized: if True, all properties return normalized (lower case) results """
|
||||
|
||||
if photo._db._db_version <= _PHOTOS_4_VERSION:
|
||||
raise NotImplementedError(
|
||||
@@ -58,6 +96,7 @@ class SearchInfo:
|
||||
)
|
||||
|
||||
self._photo = photo
|
||||
self._normalized = normalized
|
||||
self.uuid = photo.uuid
|
||||
try:
|
||||
# get search info for this UUID
|
||||
@@ -69,25 +108,170 @@ class SearchInfo:
|
||||
@property
|
||||
def labels(self):
|
||||
""" return list of labels associated with Photo """
|
||||
if self._db_searchinfo:
|
||||
labels = [
|
||||
rec["content_string"]
|
||||
for rec in self._db_searchinfo
|
||||
if rec["category"] == SEARCH_CATEGORY_LABEL
|
||||
]
|
||||
else:
|
||||
labels = []
|
||||
return labels
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_LABEL)
|
||||
|
||||
@property
|
||||
def labels_normalized(self):
|
||||
""" return list of normalized labels associated with Photo """
|
||||
def place_names(self):
|
||||
""" returns list of place names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME)
|
||||
|
||||
@property
|
||||
def streets(self):
|
||||
""" returns list of street names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_STREET)
|
||||
|
||||
@property
|
||||
def neighborhoods(self):
|
||||
""" returns list of neighborhoods """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD)
|
||||
|
||||
@property
|
||||
def locality_names(self):
|
||||
""" returns list of other locality names """
|
||||
locality = []
|
||||
for category in SEARCH_CATEGORY_ALL_LOCALITY:
|
||||
locality += self._get_text_for_category(category)
|
||||
return locality
|
||||
|
||||
@property
|
||||
def city(self):
|
||||
""" returns city/town """
|
||||
city = self._get_text_for_category(SEARCH_CATEGORY_CITY)
|
||||
return city[0] if city else ""
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" returns state name """
|
||||
state = self._get_text_for_category(SEARCH_CATEGORY_STATE)
|
||||
return state[0] if state else ""
|
||||
|
||||
@property
|
||||
def state_abbreviation(self):
|
||||
""" returns state abbreviation """
|
||||
abbrev = self._get_text_for_category(SEARCH_CATEGORY_STATE_ABBREVIATION)
|
||||
return abbrev[0] if abbrev else ""
|
||||
|
||||
@property
|
||||
def country(self):
|
||||
""" returns country name """
|
||||
country = self._get_text_for_category(SEARCH_CATEGORY_COUNTRY)
|
||||
return country[0] if country else ""
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" returns month name """
|
||||
month = self._get_text_for_category(SEARCH_CATEGORY_MONTH)
|
||||
return month[0] if month else ""
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" returns year """
|
||||
year = self._get_text_for_category(SEARCH_CATEGORY_YEAR)
|
||||
return year[0] if year else ""
|
||||
|
||||
@property
|
||||
def bodies_of_water(self):
|
||||
""" returns list of body of water names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_BODY_OF_WATER)
|
||||
|
||||
@property
|
||||
def holidays(self):
|
||||
""" returns list of holiday names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY)
|
||||
|
||||
@property
|
||||
def activities(self):
|
||||
""" returns list of activity names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY)
|
||||
|
||||
@property
|
||||
def season(self):
|
||||
""" returns season name """
|
||||
season = self._get_text_for_category(SEARCH_CATEGORY_SEASON)
|
||||
return season[0] if season else ""
|
||||
|
||||
@property
|
||||
def venues(self):
|
||||
""" returns list of venue names """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_VENUE)
|
||||
|
||||
@property
|
||||
def venue_types(self):
|
||||
""" returns list of venue types """
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_VENUE_TYPE)
|
||||
|
||||
@property
|
||||
def media_types(self):
|
||||
""" returns list of media types (photo, video, panorama, etc) """
|
||||
types = []
|
||||
for category in SEARCH_CATEGORY_MEDIA_TYPES:
|
||||
types += self._get_text_for_category(category)
|
||||
return types
|
||||
|
||||
@property
|
||||
def all(self):
|
||||
""" return all search info properties in a single list """
|
||||
all = (
|
||||
self.labels
|
||||
+ self.place_names
|
||||
+ self.streets
|
||||
+ self.neighborhoods
|
||||
+ self.locality_names
|
||||
+ self.bodies_of_water
|
||||
+ self.holidays
|
||||
+ self.activities
|
||||
+ self.venues
|
||||
+ self.venue_types
|
||||
+ self.media_types
|
||||
)
|
||||
if self.city:
|
||||
all += [self.city]
|
||||
if self.state:
|
||||
all += [self.state]
|
||||
if self.state_abbreviation:
|
||||
all += [self.state_abbreviation]
|
||||
if self.country:
|
||||
all += [self.country]
|
||||
if self.month:
|
||||
all += [self.month]
|
||||
if self.year:
|
||||
all += [self.year]
|
||||
if self.season:
|
||||
all += [self.season]
|
||||
|
||||
return all
|
||||
|
||||
def asdict(self):
|
||||
""" return dict of search info """
|
||||
return {
|
||||
"labels": self.labels,
|
||||
"place_names": self.place_names,
|
||||
"streets": self.streets,
|
||||
"neighborhoods": self.neighborhoods,
|
||||
"city": self.city,
|
||||
"locality_names": self.locality_names,
|
||||
"state": self.state,
|
||||
"state_abbreviation": self.state_abbreviation,
|
||||
"country": self.country,
|
||||
"bodies_of_water": self.bodies_of_water,
|
||||
"month": self.month,
|
||||
"year": self.year,
|
||||
"holidays": self.holidays,
|
||||
"activities": self.activities,
|
||||
"season": self.season,
|
||||
"venues": self.venues,
|
||||
"venue_types": self.venue_types,
|
||||
"media_types": self.media_types,
|
||||
}
|
||||
|
||||
def _get_text_for_category(self, category):
|
||||
""" return list of text for a specified category ID """
|
||||
if self._db_searchinfo:
|
||||
labels = [
|
||||
rec["normalized_string"]
|
||||
content = "normalized_string" if self._normalized else "content_string"
|
||||
return [
|
||||
rec[content]
|
||||
for rec in self._db_searchinfo
|
||||
if rec["category"] == SEARCH_CATEGORY_LABEL
|
||||
if rec["category"] == category
|
||||
]
|
||||
else:
|
||||
labels = []
|
||||
return labels
|
||||
return []
|
||||
|
||||
@@ -5,6 +5,7 @@ PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -24,6 +25,7 @@ from .._constants import (
|
||||
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND,
|
||||
_PHOTOS_5_SHARED_ALBUM_KIND,
|
||||
_PHOTOS_5_SHARED_PHOTO_PATH,
|
||||
_PHOTOS_5_VERSION,
|
||||
)
|
||||
from ..albuminfo import AlbumInfo, ImportInfo
|
||||
from ..personinfo import FaceInfo, PersonInfo
|
||||
@@ -41,6 +43,7 @@ class PhotoInfo:
|
||||
# import additional methods
|
||||
from ._photoinfo_searchinfo import (
|
||||
search_info,
|
||||
search_info_normalized,
|
||||
labels,
|
||||
labels_normalized,
|
||||
SearchInfo,
|
||||
@@ -51,13 +54,17 @@ class PhotoInfo:
|
||||
export,
|
||||
export2,
|
||||
_export_photo,
|
||||
_exiftool_dict,
|
||||
_exiftool_json_sidecar,
|
||||
_get_exif_keywords,
|
||||
_get_exif_persons,
|
||||
_write_exif_data,
|
||||
_write_sidecar,
|
||||
_xmp_sidecar,
|
||||
ExportResults,
|
||||
)
|
||||
from ._photoinfo_scoreinfo import score, ScoreInfo
|
||||
from ._photoinfo_comments import comments, likes
|
||||
|
||||
def __init__(self, db=None, uuid=None, info=None):
|
||||
self._uuid = uuid
|
||||
@@ -67,19 +74,30 @@ class PhotoInfo:
|
||||
@property
|
||||
def filename(self):
|
||||
""" filename of the picture """
|
||||
# sourcery off
|
||||
if self.has_raw and self.raw_original:
|
||||
# return name of the RAW file
|
||||
# TODO: not yet implemented
|
||||
return self._info["filename"]
|
||||
if (
|
||||
self._db._db_version <= _PHOTOS_4_VERSION
|
||||
and self.has_raw
|
||||
and self.raw_original
|
||||
):
|
||||
# return the JPEG version as that's what Photos 5+ does
|
||||
return self._info["raw_pair_info"]["filename"]
|
||||
else:
|
||||
return self._info["filename"]
|
||||
|
||||
@property
|
||||
def original_filename(self):
|
||||
""" original filename of the picture
|
||||
Photos 5 mangles filenames upon import """
|
||||
return self._info["originalFilename"]
|
||||
"""original filename of the picture
|
||||
Photos 5 mangles filenames upon import"""
|
||||
if (
|
||||
self._db._db_version <= _PHOTOS_4_VERSION
|
||||
and self.has_raw
|
||||
and self.raw_original
|
||||
):
|
||||
# return the JPEG version as that's what Photos 5+ does
|
||||
original_name = self._info["raw_pair_info"]["originalFilename"]
|
||||
else:
|
||||
original_name = self._info["originalFilename"]
|
||||
return original_name or self.filename
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
@@ -88,14 +106,22 @@ class PhotoInfo:
|
||||
|
||||
@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)
|
||||
return imagedate.astimezone(tz=tz)
|
||||
"""image modification date as timezone aware datetime object
|
||||
or None if no modification date set"""
|
||||
|
||||
# Photos <= 4 provides no way to get date of adjustment and will update
|
||||
# lastmodifieddate anytime photo database record is updated (e.g. adding tags)
|
||||
# only report lastmodified date for Photos <=4 if photo is edited;
|
||||
# even in this case, the date could be incorrect
|
||||
if self.hasadjustments or self._db._db_version > _PHOTOS_4_VERSION:
|
||||
imagedate = self._info["lastmodifieddate"]
|
||||
if imagedate:
|
||||
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
|
||||
delta = timedelta(seconds=seconds)
|
||||
tz = timezone(delta)
|
||||
return imagedate.astimezone(tz=tz)
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -107,151 +133,203 @@ class PhotoInfo:
|
||||
@property
|
||||
def path(self):
|
||||
""" absolute path on disk of the original picture """
|
||||
try:
|
||||
return self._path
|
||||
except AttributeError:
|
||||
self._path = None
|
||||
photopath = None
|
||||
if self._info["isMissing"] == 1:
|
||||
return photopath # path would be meaningless until downloaded
|
||||
|
||||
photopath = None
|
||||
if self._info["isMissing"] == 1:
|
||||
return photopath # path would be meaningless until downloaded
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self._info["has_raw"]:
|
||||
# return the path to JPEG even if RAW is original
|
||||
vol = (
|
||||
self._db._dbvolumes[self._info["raw_pair_info"]["volumeId"]]
|
||||
if self._info["raw_pair_info"]["volumeId"] is not None
|
||||
else None
|
||||
)
|
||||
if vol is not None:
|
||||
photopath = os.path.join(
|
||||
"/Volumes", vol, self._info["raw_pair_info"]["imagePath"]
|
||||
)
|
||||
else:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path,
|
||||
self._info["raw_pair_info"]["imagePath"],
|
||||
)
|
||||
else:
|
||||
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 not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
return photopath
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
vol = self._info["volume"]
|
||||
if vol is not None:
|
||||
photopath = os.path.join("/Volumes", vol, self._info["imagePath"])
|
||||
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"],
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
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["imagePath"]
|
||||
self._db._masters_path,
|
||||
self._info["directory"],
|
||||
self._info["filename"],
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
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
|
||||
try:
|
||||
return self._path_edited
|
||||
except AttributeError:
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
self._path_edited = self._path_edited_4()
|
||||
else:
|
||||
self._path_edited = self._path_edited_5()
|
||||
|
||||
return self._path_edited
|
||||
|
||||
def _path_edited_5(self):
|
||||
""" return path_edited for Photos >= 5 """
|
||||
# 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
|
||||
#
|
||||
# In Photos 6.0 / Big Sur, the edited image is a .heic if the photo isn't a jpeg,
|
||||
# otherwise it's a jpeg. It could also be a jpeg if photo library upgraded from earlier
|
||||
# version.
|
||||
|
||||
if self._db._db_version < _PHOTOS_5_VERSION:
|
||||
raise RuntimeError("Wrong database format!")
|
||||
|
||||
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
|
||||
if self._db._photos_ver == 5:
|
||||
filename = f"{self._uuid}_1_201_a.jpeg"
|
||||
else:
|
||||
# could be a heic or a jpeg
|
||||
if self.uti == "public.heic":
|
||||
filename = f"{self._uuid}_1_201_a.heic"
|
||||
else:
|
||||
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
|
||||
|
||||
return photopath
|
||||
|
||||
def _path_edited_4(self):
|
||||
""" return path_edited for Photos <= 4 """
|
||||
|
||||
if self._db._db_version > _PHOTOS_4_VERSION:
|
||||
raise RuntimeError("Wrong database format!")
|
||||
|
||||
photopath = None
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self._info["hasAdjustments"]:
|
||||
edit_id = self._info["edit_resource_id"]
|
||||
if edit_id is not None:
|
||||
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"]:
|
||||
if self._info["hasAdjustments"]:
|
||||
edit_id = self._info["edit_resource_id"]
|
||||
if edit_id is not None:
|
||||
library = self._db._library_path
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
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"{self._uuid}_1_201_a.jpeg"
|
||||
filename = f"fullsizeoutput_{file_id}.jpeg"
|
||||
elif self._info["type"] == _MOVIE_TYPE:
|
||||
# it's a movie
|
||||
filename = f"{self._uuid}_2_0_a.mov"
|
||||
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", "renders", directory, filename
|
||||
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"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
|
||||
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
|
||||
|
||||
# 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)
|
||||
else:
|
||||
photopath = None
|
||||
|
||||
return photopath
|
||||
|
||||
@@ -414,9 +492,9 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def ismissing(self):
|
||||
""" returns true if photo is missing from disk (which means it's not been downloaded from iCloud)
|
||||
"""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
|
||||
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
|
||||
@@ -461,8 +539,8 @@ class PhotoInfo:
|
||||
|
||||
@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 """
|
||||
"""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_4_VERSION:
|
||||
return self._info["shared"]
|
||||
else:
|
||||
@@ -470,43 +548,74 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def uti(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""Returns Uniform Type Identifier (UTI) for the image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""
|
||||
return self._info["UTI"]
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
if self.hasadjustments:
|
||||
return self._info["UTI_edited"]
|
||||
elif self.has_raw and self.raw_original:
|
||||
# return UTI of the non-raw image to match Photos 5+ behavior
|
||||
return self._info["raw_pair_info"]["UTI"]
|
||||
else:
|
||||
return self._info["UTI"]
|
||||
else:
|
||||
return self._info["UTI"]
|
||||
|
||||
@property
|
||||
def uti_original(self):
|
||||
"""Returns Uniform Type Identifier (UTI) for the original image
|
||||
for example: public.jpeg or com.apple.quicktime-movie
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self._info["has_raw"]:
|
||||
return self._info["raw_pair_info"]["UTI"]
|
||||
elif self.shared:
|
||||
# TODO: need reliable way to get original UTI for shared
|
||||
return self.uti
|
||||
else:
|
||||
return self._info["UTI_original"]
|
||||
|
||||
@property
|
||||
def uti_edited(self):
|
||||
"""Returns Uniform Type Identifier (UTI) for the edited image
|
||||
if the photo has been edited, otherwise None;
|
||||
for example: public.jpeg
|
||||
"""
|
||||
if self._db._db_version >= _PHOTOS_5_VERSION:
|
||||
return self.uti if self.hasadjustments else None
|
||||
else:
|
||||
return self._info["UTI_edited"]
|
||||
|
||||
@property
|
||||
def uti_raw(self):
|
||||
""" Returns Uniform Type Identifier (UTI) for the RAW image if there is one
|
||||
for example: com.canon.cr2-raw-image
|
||||
Returns None if no associated RAW image
|
||||
"""Returns Uniform Type Identifier (UTI) for the RAW image if there is one
|
||||
for example: com.canon.cr2-raw-image
|
||||
Returns None if no associated RAW image
|
||||
"""
|
||||
return self._info["UTI_raw"]
|
||||
|
||||
@property
|
||||
def ismovie(self):
|
||||
""" Returns True if file is a movie, otherwise False
|
||||
"""
|
||||
"""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
|
||||
"""
|
||||
"""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
|
||||
"""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
|
||||
"""Returns True if photo is a cloud asset (in an iCloud library),
|
||||
otherwise False
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return (
|
||||
@@ -525,9 +634,9 @@ class PhotoInfo:
|
||||
|
||||
@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 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"]
|
||||
return [
|
||||
@@ -545,9 +654,9 @@ class PhotoInfo:
|
||||
|
||||
@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 """
|
||||
"""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_4_VERSION:
|
||||
@@ -664,14 +773,19 @@ class PhotoInfo:
|
||||
|
||||
@property
|
||||
def has_raw(self):
|
||||
""" returns True if photo has an associated RAW image, otherwise False """
|
||||
""" returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False """
|
||||
return self._info["has_raw"]
|
||||
|
||||
@property
|
||||
def israw(self):
|
||||
""" returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw """
|
||||
return "raw-image" in self.uti_original
|
||||
|
||||
@property
|
||||
def raw_original(self):
|
||||
""" returns True if associated RAW image and the RAW image is selected in Photos
|
||||
via "Use RAW as Original "
|
||||
otherwise returns False """
|
||||
"""returns True if associated raw image and the raw image is selected in Photos
|
||||
via "Use RAW as Original "
|
||||
otherwise returns False"""
|
||||
return self._info["raw_is_original"]
|
||||
|
||||
@property
|
||||
@@ -716,29 +830,35 @@ class PhotoInfo:
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
):
|
||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||
|
||||
Args:
|
||||
template_str: a template string with fields to render
|
||||
none_str: a str to use if template field renders to None, default is "_".
|
||||
path_sep: a single character str to use as path separator when joining
|
||||
path_sep: a single character str to use as path separator when joining
|
||||
fields like folder_album; if not provided, defaults to os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
"""
|
||||
template = PhotoTemplate(self)
|
||||
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
|
||||
return template.render(
|
||||
template_str,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -752,11 +872,11 @@ class PhotoInfo:
|
||||
return self._info["latitude"]
|
||||
|
||||
def _get_album_uuids(self):
|
||||
""" Return list of album UUIDs this photo is found in
|
||||
|
||||
"""Return list of album UUIDs this photo is found in
|
||||
|
||||
Filters out albums in the trash and any special album types
|
||||
|
||||
Returns: list of album UUIDs
|
||||
Returns: list of album UUIDs
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
version4 = True
|
||||
@@ -848,22 +968,24 @@ class PhotoInfo:
|
||||
}
|
||||
return yaml.dump(info, sort_keys=False)
|
||||
|
||||
def json(self):
|
||||
""" return JSON representation """
|
||||
def asdict(self):
|
||||
""" return dict representation """
|
||||
|
||||
date_modified_iso = (
|
||||
self.date_modified.isoformat() if self.date_modified else None
|
||||
)
|
||||
folders = {album.title: album.folder_names for album in self.album_info}
|
||||
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
|
||||
place = self.place.as_dict() if self.place else {}
|
||||
place = self.place.asdict() if self.place else {}
|
||||
score = dataclasses.asdict(self.score) if self.score else {}
|
||||
comments = [comment.asdict() for comment in self.comments]
|
||||
likes = [like.asdict() for like in self.likes]
|
||||
faces = [face.asdict() for face in self.face_info]
|
||||
search_info = self.search_info.asdict() if self.search_info else {}
|
||||
|
||||
pic = {
|
||||
return {
|
||||
"library": self._db._library_path,
|
||||
"uuid": self.uuid,
|
||||
"filename": self.filename,
|
||||
"original_filename": self.original_filename,
|
||||
"date": self.date.isoformat(),
|
||||
"date": self.date,
|
||||
"description": self.description,
|
||||
"title": self.title,
|
||||
"keywords": self.keywords,
|
||||
@@ -872,6 +994,7 @@ class PhotoInfo:
|
||||
"albums": self.albums,
|
||||
"folders": folders,
|
||||
"persons": self.persons,
|
||||
"faces": faces,
|
||||
"path": self.path,
|
||||
"ismissing": self.ismissing,
|
||||
"hasadjustments": self.hasadjustments,
|
||||
@@ -885,12 +1008,13 @@ class PhotoInfo:
|
||||
"isphoto": self.isphoto,
|
||||
"ismovie": self.ismovie,
|
||||
"uti": self.uti,
|
||||
"uti_original": self.uti_original,
|
||||
"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,
|
||||
"date_modified": self.date_modified,
|
||||
"portrait": self.portrait,
|
||||
"screenshot": self.screenshot,
|
||||
"slow_mo": self.slow_mo,
|
||||
@@ -899,6 +1023,8 @@ class PhotoInfo:
|
||||
"selfie": self.selfie,
|
||||
"panorama": self.panorama,
|
||||
"has_raw": self.has_raw,
|
||||
"israw": self.israw,
|
||||
"raw_original": self.raw_original,
|
||||
"uti_raw": self.uti_raw,
|
||||
"path_raw": self.path_raw,
|
||||
"place": place,
|
||||
@@ -912,8 +1038,19 @@ class PhotoInfo:
|
||||
"original_width": self.original_width,
|
||||
"original_orientation": self.original_orientation,
|
||||
"original_filesize": self.original_filesize,
|
||||
"comments": comments,
|
||||
"likes": likes,
|
||||
"search_info": search_info,
|
||||
}
|
||||
return json.dumps(pic)
|
||||
|
||||
def json(self):
|
||||
""" Return JSON representation """
|
||||
|
||||
def default(o):
|
||||
if isinstance(o, (datetime.date, datetime.datetime)):
|
||||
return o.isoformat()
|
||||
|
||||
return json.dumps(self.asdict(), sort_keys=True, default=default)
|
||||
|
||||
def __eq__(self, other):
|
||||
""" Compare two PhotoInfo objects for equality """
|
||||
|
||||
1215
osxphotos/photokit.py
Normal file
157
osxphotos/photosdb/_photosdb_process_comments.py
Normal file
@@ -0,0 +1,157 @@
|
||||
""" PhotosDB method for processing comments and likes on shared photos.
|
||||
Do not import this module directly """
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION, TIME_DELTA
|
||||
from ..utils import _open_sql_file, normalize_unicode
|
||||
|
||||
|
||||
def _process_comments(self):
|
||||
""" load the comments and likes data from the database
|
||||
this is a PhotosDB method that should be imported in
|
||||
the PhotosDB class definition in photosdb.py
|
||||
"""
|
||||
self._db_hashed_person_id = {}
|
||||
self._db_comments_uuid = {}
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
_process_comments_4(self)
|
||||
else:
|
||||
_process_comments_5(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommentInfo:
|
||||
""" Class for shared photo comments """
|
||||
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
ismine: bool
|
||||
text: str
|
||||
|
||||
def asdict(self):
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LikeInfo:
|
||||
""" Class for shared photo likes """
|
||||
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
ismine: bool
|
||||
|
||||
def asdict(self):
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
|
||||
# The following methods do not get imported into PhotosDB
|
||||
# but will get called by _process_comments
|
||||
def _process_comments_4(photosdb):
|
||||
""" process comments and likes info for Photos <= 4
|
||||
photosdb: PhotosDB instance """
|
||||
raise NotImplementedError(
|
||||
f"Not implemented for database version {photosdb._db_version}."
|
||||
)
|
||||
|
||||
|
||||
def _process_comments_5(photosdb):
|
||||
""" process comments and likes info for Photos >= 5
|
||||
photosdb: PhotosDB instance """
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
results = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
ZINVITEEHASHEDPERSONID,
|
||||
ZINVITEEFIRSTNAME,
|
||||
ZINVITEELASTNAME,
|
||||
ZINVITEEFULLNAME
|
||||
FROM
|
||||
ZCLOUDSHAREDALBUMINVITATIONRECORD
|
||||
"""
|
||||
)
|
||||
|
||||
# order of results
|
||||
# 0: ZINVITEEHASHEDPERSONID,
|
||||
# 1: ZINVITEEFIRSTNAME,
|
||||
# 2: ZINVITEELASTNAME,
|
||||
# 3: ZINVITEEFULLNAME
|
||||
|
||||
photosdb._db_hashed_person_id = {}
|
||||
for row in results.fetchall():
|
||||
person_id = row[0]
|
||||
photosdb._db_hashed_person_id[person_id] = {
|
||||
"first_name": normalize_unicode(row[1]),
|
||||
"last_name": normalize_unicode(row[2]),
|
||||
"full_name": normalize_unicode(row[3]),
|
||||
}
|
||||
|
||||
results = conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
{asset_table}.ZUUID, -- UUID of the photo
|
||||
ZCLOUDSHAREDCOMMENT.ZISLIKE, -- comment is actually a "like"
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, -- date of comment
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, -- text of comment
|
||||
ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, -- hashed ID of person who made comment/like
|
||||
ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT -- is my (this user's) comment
|
||||
FROM ZCLOUDSHAREDCOMMENT
|
||||
JOIN {asset_table} ON
|
||||
{asset_table}.Z_PK = ZCLOUDSHAREDCOMMENT.ZCOMMENTEDASSET
|
||||
OR
|
||||
{asset_table}.Z_PK = ZCLOUDSHAREDCOMMENT.ZLIKEDASSET
|
||||
"""
|
||||
)
|
||||
|
||||
# order of results
|
||||
# 0: ZGENERICASSET.ZUUID, -- UUID of the photo
|
||||
# 1: ZCLOUDSHAREDCOMMENT.ZISLIKE, -- comment is actually a "like"
|
||||
# 2: ZCLOUDSHAREDCOMMENT.ZCOMMENTDATE, -- date of comment
|
||||
# 3: ZCLOUDSHAREDCOMMENT.ZCOMMENTTEXT, -- text of comment
|
||||
# 4: ZCLOUDSHAREDCOMMENT.ZCOMMENTERHASHEDPERSONID, -- hashed ID of person who made comment/like
|
||||
# 5: ZCLOUDSHAREDCOMMENT.ZISMYCOMMENT -- is my (this user's) comment
|
||||
|
||||
photosdb._db_comments_uuid = {}
|
||||
for row in results:
|
||||
uuid = row[0]
|
||||
is_like = bool(row[1])
|
||||
text = normalize_unicode(row[3])
|
||||
try:
|
||||
user_name = photosdb._db_hashed_person_id[row[4]]["full_name"]
|
||||
except KeyError:
|
||||
user_name = None
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.fromtimestamp(row[2] + TIME_DELTA)
|
||||
except:
|
||||
dt = datetime.datetime(1970, 1, 1)
|
||||
|
||||
ismine = bool(row[5])
|
||||
|
||||
try:
|
||||
db_comments = photosdb._db_comments_uuid[uuid]
|
||||
except KeyError:
|
||||
photosdb._db_comments_uuid[uuid] = {"likes": [], "comments": []}
|
||||
db_comments = photosdb._db_comments_uuid[uuid]
|
||||
|
||||
if is_like:
|
||||
db_comments["likes"].append(LikeInfo(dt, user_name, ismine))
|
||||
elif text:
|
||||
db_comments["comments"].append(CommentInfo(dt, user_name, ismine, text))
|
||||
|
||||
# sort results
|
||||
for uuid in photosdb._db_comments_uuid:
|
||||
if photosdb._db_comments_uuid[uuid]["likes"]:
|
||||
photosdb._db_comments_uuid[uuid]["likes"].sort(key=lambda x: x.datetime)
|
||||
if photosdb._db_comments_uuid[uuid]["comments"]:
|
||||
photosdb._db_comments_uuid[uuid]["comments"].sort(key=lambda x: x.datetime)
|
||||
|
||||
conn.close()
|
||||
@@ -4,7 +4,7 @@
|
||||
import logging
|
||||
|
||||
from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
|
||||
from ..utils import _open_sql_file
|
||||
from ..utils import _open_sql_file, normalize_unicode
|
||||
from .photosdb_utils import get_db_version
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ def _process_faceinfo_4(photosdb):
|
||||
face["asset_uuid"] = asset_uuid
|
||||
face["uuid"] = row[2]
|
||||
face["person"] = person_id
|
||||
face["fullname"] = row[3]
|
||||
face["fullname"] = normalize_unicode(row[3])
|
||||
face["sourcewidth"] = row[7]
|
||||
face["sourceheight"] = row[8]
|
||||
face["centerx"] = row[9]
|
||||
@@ -282,7 +282,7 @@ def _process_faceinfo_5(photosdb):
|
||||
face["asset_uuid"] = asset_uuid
|
||||
face["uuid"] = row[2]
|
||||
face["person"] = person_pk
|
||||
face["fullname"] = row[4]
|
||||
face["fullname"] = normalize_unicode(row[4])
|
||||
face["agetype"] = row[5]
|
||||
face["baldtype"] = row[6]
|
||||
face["eyemakeuptype"] = row[7]
|
||||
|
||||
@@ -10,7 +10,7 @@ import uuid as uuidlib
|
||||
from pprint import pformat
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION, SEARCH_CATEGORY_LABEL
|
||||
from ..utils import _db_is_locked, _debug, _open_sql_file
|
||||
from ..utils import _db_is_locked, _debug, _open_sql_file, normalize_unicode
|
||||
|
||||
"""
|
||||
This module should be imported in the class defintion of PhotosDB in photosdb.py
|
||||
@@ -104,17 +104,19 @@ def _process_searchinfo(self):
|
||||
for row in c:
|
||||
uuid = ints_to_uuid(row[1], row[2])
|
||||
# strings have null character appended, so strip it
|
||||
record = {}
|
||||
record["uuid"] = uuid
|
||||
record["rowid"] = row[0]
|
||||
record["uuid_0"] = row[1]
|
||||
record["uuid_1"] = row[2]
|
||||
record["groupid"] = row[3]
|
||||
record["category"] = row[4]
|
||||
record["owning_groupid"] = row[5]
|
||||
record["content_string"] = row[6].replace("\x00", "")
|
||||
record["normalized_string"] = row[7].replace("\x00", "")
|
||||
record["lookup_identifier"] = row[8]
|
||||
record = {
|
||||
"uuid": uuid,
|
||||
"rowid": row[0],
|
||||
"uuid_0": row[1],
|
||||
"uuid_1": row[2],
|
||||
"groupid": row[3],
|
||||
"category": row[4],
|
||||
"owning_groupid": row[5],
|
||||
"content_string": normalize_unicode(row[6].replace("\x00", "")),
|
||||
}
|
||||
|
||||
record["normalized_string"] = normalize_unicode(row[7].replace("\x00", ""))
|
||||
record["lookup_identifier"] = normalize_unicode(row[8].replace("\x00", ""))
|
||||
|
||||
try:
|
||||
_db_searchinfo_uuid[uuid].append(record)
|
||||
@@ -147,9 +149,10 @@ def _process_searchinfo(self):
|
||||
"_db_searchinfo_labels_normalized: \n"
|
||||
+ pformat(self._db_searchinfo_labels_normalized)
|
||||
)
|
||||
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" return list of all search info labels found in the library """
|
||||
|
||||
@@ -12,7 +12,6 @@ import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pprint import pformat
|
||||
from shutil import copyfile
|
||||
|
||||
from .._constants import (
|
||||
_DB_TABLE_NAMES,
|
||||
@@ -35,6 +34,7 @@ from .._constants import (
|
||||
from .._version import __version__
|
||||
from ..albuminfo import AlbumInfo, FolderInfo, ImportInfo
|
||||
from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
|
||||
from ..fileutil import FileUtil
|
||||
from ..personinfo import PersonInfo
|
||||
from ..photoinfo import PhotoInfo
|
||||
from ..utils import (
|
||||
@@ -44,6 +44,8 @@ from ..utils import (
|
||||
_get_os_version,
|
||||
_open_sql_file,
|
||||
get_last_library_path,
|
||||
noop,
|
||||
normalize_unicode,
|
||||
)
|
||||
from .photosdb_utils import get_db_model_version, get_db_version
|
||||
|
||||
@@ -66,12 +68,20 @@ class PhotosDB:
|
||||
labels_normalized_as_dict,
|
||||
)
|
||||
from ._photosdb_process_scoreinfo import _process_scoreinfo
|
||||
from ._photosdb_process_comments import _process_comments
|
||||
|
||||
def __init__(self, *dbfile_, dbfile=None):
|
||||
""" create a new PhotosDB object
|
||||
path to photos library or database may be specified EITHER as first argument or as named argument dbfile=path
|
||||
specify full path to photos library or photos.db as first argument
|
||||
specify path to photos library or photos.db using named argument dbfile=path """
|
||||
def __init__(self, dbfile=None, verbose=None, exiftool=None):
|
||||
""" Create a new PhotosDB object.
|
||||
|
||||
Args:
|
||||
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
|
||||
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
|
||||
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if dbfile is not a valid Photos library.
|
||||
TypeError if verbose is not None and not callable.
|
||||
"""
|
||||
|
||||
# Check OS version
|
||||
system = platform.system()
|
||||
@@ -83,6 +93,14 @@ class PhotosDB:
|
||||
f"you have {system}, OS version: {major}"
|
||||
)
|
||||
|
||||
if verbose is None:
|
||||
verbose = noop
|
||||
elif not callable(verbose):
|
||||
raise TypeError("verbose must be callable")
|
||||
self._verbose = verbose
|
||||
|
||||
self._exiftool_path = exiftool
|
||||
|
||||
# create a temporary directory
|
||||
# tempfile.TemporaryDirectory gets cleaned up when the object does
|
||||
self._tempdir = tempfile.TemporaryDirectory(prefix="osxphotos_")
|
||||
@@ -215,25 +233,7 @@ class PhotosDB:
|
||||
if _debug():
|
||||
logging.debug(f"dbfile = {dbfile}")
|
||||
|
||||
# get the path to photos library database
|
||||
if dbfile_:
|
||||
# got a library path as argument
|
||||
if dbfile:
|
||||
# shouldn't pass via both *args and dbfile=
|
||||
raise TypeError(
|
||||
f"photos database path must be specified as argument or "
|
||||
f"named parameter dbfile but not both: args: {dbfile_}, dbfile: {dbfile}",
|
||||
dbfile_,
|
||||
dbfile,
|
||||
)
|
||||
elif len(dbfile_) == 1:
|
||||
dbfile = dbfile_[0]
|
||||
else:
|
||||
raise TypeError(
|
||||
f"__init__ takes only a single argument (photos database path): {dbfile_}",
|
||||
dbfile_,
|
||||
)
|
||||
elif dbfile is None:
|
||||
if dbfile is None:
|
||||
dbfile = get_last_library_path()
|
||||
if dbfile is None:
|
||||
# get_last_library_path must have failed to find library
|
||||
@@ -261,11 +261,14 @@ class PhotosDB:
|
||||
# or photosanalysisd
|
||||
self._dbfile = self._dbfile_actual = self._tmp_db = os.path.abspath(dbfile)
|
||||
|
||||
verbose(f"Processing database {self._dbfile}")
|
||||
|
||||
# if database is exclusively locked, make a copy of it and use the copy
|
||||
# Photos maintains an exclusive lock on the database file while Photos is open
|
||||
# photoanalysisd sometimes maintains this lock even after Photos is closed
|
||||
# In those cases, make a temp copy of the file for sqlite3 to read
|
||||
if _db_is_locked(self._dbfile):
|
||||
verbose(f"Database locked, creating temporary copy.")
|
||||
self._tmp_db = self._copy_db_file(self._dbfile)
|
||||
|
||||
self._db_version = get_db_version(self._tmp_db)
|
||||
@@ -278,8 +281,10 @@ class PhotosDB:
|
||||
raise FileNotFoundError(f"dbfile {dbfile} does not exist", dbfile)
|
||||
else:
|
||||
self._dbfile_actual = self._tmp_db = dbfile
|
||||
verbose(f"Processing database {self._dbfile_actual}")
|
||||
# if database is exclusively locked, make a copy of it and use the copy
|
||||
if _db_is_locked(self._dbfile_actual):
|
||||
verbose(f"Database locked, creating temporary copy.")
|
||||
self._tmp_db = self._copy_db_file(self._dbfile_actual)
|
||||
|
||||
if _debug():
|
||||
@@ -529,14 +534,14 @@ class PhotosDB:
|
||||
try:
|
||||
dest_name = pathlib.Path(fname).name
|
||||
dest_path = os.path.join(self._tempdir_name, dest_name)
|
||||
copyfile(fname, dest_path)
|
||||
FileUtil.copy(fname, dest_path)
|
||||
# copy write-ahead log and shared memory files (-wal and -shm) files if they exist
|
||||
if os.path.exists(f"{fname}-wal"):
|
||||
copyfile(f"{fname}-wal", f"{dest_path}-wal")
|
||||
FileUtil.copy(f"{fname}-wal", f"{dest_path}-wal")
|
||||
if os.path.exists(f"{fname}-shm"):
|
||||
copyfile(f"{fname}-shm", f"{dest_path}-shm")
|
||||
FileUtil.copy(f"{fname}-shm", f"{dest_path}-shm")
|
||||
except:
|
||||
print("Error copying " + fname + " to " + dest_path, file=sys.stderr)
|
||||
print(f"Error copying{fname} to {dest_path}", file=sys.stderr)
|
||||
raise Exception
|
||||
|
||||
if _debug():
|
||||
@@ -544,14 +549,46 @@ class PhotosDB:
|
||||
|
||||
return dest_path
|
||||
|
||||
# NOTE: This method seems to cause problems with applescript
|
||||
# Bummer...would'be been nice to avoid copying the DB
|
||||
# def _link_db_file(self, fname):
|
||||
# """ links the sqlite database file to a temp file """
|
||||
# """ returns the name of the temp file """
|
||||
# """ If sqlite shared memory and write-ahead log files exist, those are copied too """
|
||||
# # required because python's sqlite3 implementation can't read a locked file
|
||||
# # _, suffix = os.path.splitext(fname)
|
||||
# dest_name = dest_path = ""
|
||||
# try:
|
||||
# dest_name = pathlib.Path(fname).name
|
||||
# dest_path = os.path.join(self._tempdir_name, dest_name)
|
||||
# FileUtil.hardlink(fname, dest_path)
|
||||
# # link write-ahead log and shared memory files (-wal and -shm) files if they exist
|
||||
# if os.path.exists(f"{fname}-wal"):
|
||||
# FileUtil.hardlink(f"{fname}-wal", f"{dest_path}-wal")
|
||||
# if os.path.exists(f"{fname}-shm"):
|
||||
# FileUtil.hardlink(f"{fname}-shm", f"{dest_path}-shm")
|
||||
# except:
|
||||
# print("Error linking " + fname + " to " + dest_path, file=sys.stderr)
|
||||
# raise Exception
|
||||
|
||||
# if _debug():
|
||||
# logging.debug(dest_path)
|
||||
|
||||
# return dest_path
|
||||
|
||||
def _process_database4(self):
|
||||
""" process the Photos database to extract info
|
||||
works on Photos version <= 4.0 """
|
||||
|
||||
verbose = self._verbose
|
||||
verbose("Processing database.")
|
||||
verbose(f"Database version: {self._db_version}.")
|
||||
|
||||
(conn, c) = _open_sql_file(self._tmp_db)
|
||||
|
||||
# get info to associate persons with photos
|
||||
# then get detected faces in each photo and link to persons
|
||||
verbose("Processing persons in photos.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKPerson.modelID,
|
||||
@@ -617,6 +654,7 @@ class PhotosDB:
|
||||
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
|
||||
|
||||
# get information on detected faces
|
||||
verbose("Processing detected faces in photos.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKPerson.modelID,
|
||||
@@ -654,6 +692,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# Get info on albums
|
||||
verbose("Processing albums.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKAlbum.uuid,
|
||||
@@ -713,7 +752,7 @@ class PhotosDB:
|
||||
for album in c:
|
||||
self._dbalbum_details[album[0]] = {
|
||||
"_uuid": album[0],
|
||||
"title": album[1],
|
||||
"title": normalize_unicode(album[1]),
|
||||
"cloudlibrarystate": album[2],
|
||||
"cloudidentifier": album[3],
|
||||
"intrash": False if album[4] == 0 else True,
|
||||
@@ -760,7 +799,7 @@ class PhotosDB:
|
||||
self._dbfolder_details[uuid] = {
|
||||
"_uuid": row[0],
|
||||
"modelId": row[1],
|
||||
"name": row[2],
|
||||
"name": normalize_unicode(row[2]),
|
||||
"isMagic": row[3],
|
||||
"intrash": row[4],
|
||||
"folderType": row[5],
|
||||
@@ -796,6 +835,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbfolder_details))
|
||||
|
||||
# Get info on keywords
|
||||
verbose("Processing keywords.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKKeyword.name,
|
||||
@@ -823,6 +863,7 @@ class PhotosDB:
|
||||
self._dbvolumes[vol[0]] = vol[1]
|
||||
|
||||
# Get photo details
|
||||
verbose("Processing photo details.")
|
||||
if self._db_version < _PHOTOS_3_VERSION:
|
||||
# Photos < 3.0 doesn't have RKVersion.selfPortrait (selfie)
|
||||
c.execute(
|
||||
@@ -845,7 +886,8 @@ class PhotosDB:
|
||||
RKMaster.height,
|
||||
RKMaster.width,
|
||||
RKMaster.orientation,
|
||||
RKMaster.fileSize
|
||||
RKMaster.fileSize,
|
||||
RKVersion.subType
|
||||
FROM RKVersion, RKMaster
|
||||
WHERE RKVersion.masterUuid = RKMaster.uuid"""
|
||||
)
|
||||
@@ -872,7 +914,8 @@ class PhotosDB:
|
||||
RKMaster.height,
|
||||
RKMaster.width,
|
||||
RKMaster.orientation,
|
||||
RKMaster.originalFileSize
|
||||
RKMaster.originalFileSize,
|
||||
RKVersion.subType
|
||||
FROM RKVersion, RKMaster
|
||||
WHERE RKVersion.masterUuid = RKMaster.uuid"""
|
||||
)
|
||||
@@ -918,6 +961,7 @@ class PhotosDB:
|
||||
# 37 RKMaster.width,
|
||||
# 38 RKMaster.orientation,
|
||||
# 39 RKMaster.originalFileSize
|
||||
# 40 RKVersion.subType
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -963,7 +1007,7 @@ class PhotosDB:
|
||||
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]["name"] = normalize_unicode(row[13])
|
||||
self._dbphotos[uuid]["isMissing"] = row[14]
|
||||
self._dbphotos[uuid]["originalFilename"] = row[15]
|
||||
self._dbphotos[uuid]["favorite"] = row[16]
|
||||
@@ -988,6 +1032,13 @@ class PhotosDB:
|
||||
|
||||
self._dbphotos[uuid]["UTI"] = row[22]
|
||||
|
||||
# The UTI in RKMaster will always be UTI of the original
|
||||
# Unlike Photos 5 which changes the UTI to match latest edit
|
||||
self._dbphotos[uuid]["UTI_original"] = row[22]
|
||||
|
||||
# UTI edited will be read from RKModelResource
|
||||
self._dbphotos[uuid]["UTI_edited"] = None
|
||||
|
||||
# handle burst photos
|
||||
# if burst photo, determine whether or not it's a selected burst photo
|
||||
self._dbphotos[uuid]["burstUUID"] = row[23]
|
||||
@@ -1054,11 +1105,6 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["cloudAvailable"] = None
|
||||
self._dbphotos[uuid]["incloud"] = None
|
||||
|
||||
# TODO: NOT YET USED -- PLACEHOLDER for RAW processing (currently only in _process_database5)
|
||||
# original resource choice (e.g. RAW or jpeg)
|
||||
self._dbphotos[uuid]["original_resource_choice"] = None
|
||||
self._dbphotos[uuid]["raw_is_original"] = None
|
||||
|
||||
# associated RAW image info
|
||||
self._dbphotos[uuid]["has_raw"] = True if row[25] == 7 else False
|
||||
self._dbphotos[uuid]["UTI_raw"] = None
|
||||
@@ -1070,6 +1116,25 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["non_raw_master_uuid"] = row[30]
|
||||
self._dbphotos[uuid]["alt_master_uuid"] = row[31]
|
||||
|
||||
# original resource choice (e.g. RAW or jpeg)
|
||||
# In Photos 5+, original_resource_choice set from:
|
||||
# ZADDITIONALASSETATTRIBUTES.ZORIGINALRESOURCECHOICE
|
||||
# = 0 if jpeg is selected as "original" in Photos (the default)
|
||||
# = 1 if RAW is selected as "original" in Photos
|
||||
# RKVersion.subType, RAW always appears to be 16
|
||||
# 4 = mov
|
||||
# 16 = RAW
|
||||
# 32 = JPEG
|
||||
# 64 = TIFF
|
||||
# 2048 = PNG
|
||||
# 32768 = HIEC
|
||||
self._dbphotos[uuid]["original_resource_choice"] = (
|
||||
1 if row[40] == 16 and self._dbphotos[uuid]["has_raw"] else 0
|
||||
)
|
||||
self._dbphotos[uuid]["raw_is_original"] = bool(
|
||||
self._dbphotos[uuid]["original_resource_choice"]
|
||||
)
|
||||
|
||||
# recently deleted items
|
||||
self._dbphotos[uuid]["intrash"] = True if row[32] == 1 else False
|
||||
|
||||
@@ -1088,6 +1153,7 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["fok_import_session"] = None
|
||||
|
||||
# get additional details from RKMaster, needed for RAW processing
|
||||
verbose("Processing additional photo details.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKMaster.uuid,
|
||||
@@ -1099,7 +1165,8 @@ class PhotosDB:
|
||||
RKMaster.modelID,
|
||||
RKMaster.fileSize,
|
||||
RKMaster.isTrulyRaw,
|
||||
RKMaster.alternateMasterUuid
|
||||
RKMaster.alternateMasterUuid,
|
||||
RKMaster.filename
|
||||
FROM RKMaster
|
||||
"""
|
||||
)
|
||||
@@ -1115,6 +1182,7 @@ class PhotosDB:
|
||||
# 7 RKMaster.fileSize,
|
||||
# 8 RKMaster.isTrulyRaw,
|
||||
# 9 RKMaster.alternateMasterUuid
|
||||
# 10 RKMaster.filename
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -1129,6 +1197,7 @@ class PhotosDB:
|
||||
info["fileSize"] = row[7]
|
||||
info["isTrulyRAW"] = row[8]
|
||||
info["alternateMasterUuid"] = row[9]
|
||||
info["filename"] = row[10]
|
||||
self._dbphotos_master[uuid] = info
|
||||
|
||||
# get details needed to find path of the edited photos
|
||||
@@ -1158,7 +1227,6 @@ class PhotosDB:
|
||||
if (
|
||||
row[1] != "UNADJUSTEDNONRAW"
|
||||
and row[1] != "UNADJUSTED"
|
||||
# and row[4] == "public.jpeg"
|
||||
and row[6] == 2
|
||||
):
|
||||
if "edit_resource_id" in self._dbphotos[uuid]:
|
||||
@@ -1172,6 +1240,7 @@ class PhotosDB:
|
||||
# should we return all edits or just most recent one?
|
||||
# For now, return most recent edit
|
||||
self._dbphotos[uuid]["edit_resource_id"] = row[2]
|
||||
self._dbphotos[uuid]["UTI_edited"] = row[4]
|
||||
|
||||
# get details on external edits
|
||||
c.execute(
|
||||
@@ -1244,7 +1313,7 @@ class PhotosDB:
|
||||
)
|
||||
|
||||
# Order of results
|
||||
# 0 RKMaster.uuid,
|
||||
# 0 RKVersion.uuid,
|
||||
# 1 RKMaster.cloudLibraryState,
|
||||
# 2 RKCloudResource.available,
|
||||
# 3 RKCloudResource.status
|
||||
@@ -1258,6 +1327,7 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["incloud"] = True if row[2] == 1 else False
|
||||
|
||||
# get location data
|
||||
verbose("Processing location data.")
|
||||
# get the country codes
|
||||
country_codes = c.execute(
|
||||
"SELECT modelID, countryCode "
|
||||
@@ -1295,11 +1365,12 @@ class PhotosDB:
|
||||
|
||||
# get the place info that matches the RKPlace modelIDs for this photo
|
||||
# (place_ids), sort by area (element 3 of the place_data tuple in places)
|
||||
# area could be None so assume 0 if it is (issue #230)
|
||||
place_names = [
|
||||
pname
|
||||
for pname in sorted(
|
||||
[places[p] for p in places if p in place_ids],
|
||||
key=lambda place: place[3],
|
||||
key=lambda place: place[3] if place[3] is not None else 0,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1316,22 +1387,34 @@ class PhotosDB:
|
||||
|
||||
# add volume name to _dbphotos_master
|
||||
for info in self._dbphotos_master.values():
|
||||
info["volume"] = (
|
||||
self._dbvolumes[info["volumeId"]]
|
||||
if info["volumeId"] is not None
|
||||
else None
|
||||
)
|
||||
# issue 230: have seen bad volumeID values
|
||||
try:
|
||||
info["volume"] = (
|
||||
self._dbvolumes[info["volumeId"]]
|
||||
if info["volumeId"] is not None
|
||||
else None
|
||||
)
|
||||
except KeyError:
|
||||
info["volume"] = None
|
||||
|
||||
# add data on RAW images
|
||||
for info in self._dbphotos.values():
|
||||
if info["has_raw"]:
|
||||
raw_uuid = info["raw_master_uuid"]
|
||||
info["raw_info"] = self._dbphotos_master[raw_uuid]
|
||||
info["UTI_raw"] = self._dbphotos_master[raw_uuid]["UTI"]
|
||||
non_raw_uuid = info["non_raw_master_uuid"]
|
||||
info["raw_pair_info"] = self._dbphotos_master[non_raw_uuid]
|
||||
else:
|
||||
info["raw_info"] = None
|
||||
info["UTI_raw"] = None
|
||||
info["raw_pair_info"] = None
|
||||
|
||||
# done with the database connection
|
||||
conn.close()
|
||||
|
||||
# process faces
|
||||
verbose("Processing face details.")
|
||||
self._process_faceinfo()
|
||||
|
||||
# add faces and keywords to photo data
|
||||
@@ -1357,13 +1440,18 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["hasAlbums"] = 0
|
||||
|
||||
if self._dbphotos[uuid]["volumeId"] is not None:
|
||||
self._dbphotos[uuid]["volume"] = self._dbvolumes[
|
||||
self._dbphotos[uuid]["volumeId"]
|
||||
]
|
||||
# issue 230: have seen bad volumeID values
|
||||
try:
|
||||
self._dbphotos[uuid]["volume"] = self._dbvolumes[
|
||||
self._dbphotos[uuid]["volumeId"]
|
||||
]
|
||||
except KeyError:
|
||||
self._dbphotos[uuid]["volume"] = None
|
||||
else:
|
||||
self._dbphotos[uuid]["volume"] = None
|
||||
|
||||
# done processing, dump debug data if requested
|
||||
verbose("Done processing details from Photos library.")
|
||||
if _debug():
|
||||
logging.debug("Faces (_dbfaces_uuid):")
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
@@ -1439,12 +1527,14 @@ class PhotosDB:
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"_process_database5")
|
||||
|
||||
verbose = self._verbose
|
||||
verbose(f"Processing database.")
|
||||
(conn, c) = _open_sql_file(self._tmp_db)
|
||||
|
||||
# some of the tables/columns have different names in different versions of Photos
|
||||
photos_ver = get_db_model_version(self._tmp_db)
|
||||
self._photos_ver = photos_ver
|
||||
verbose(f"Database version: {self._db_version}, {photos_ver}.")
|
||||
asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
|
||||
keyword_join = _DB_TABLE_NAMES[photos_ver]["KEYWORD_JOIN"]
|
||||
album_join = _DB_TABLE_NAMES[photos_ver]["ALBUM_JOIN"]
|
||||
@@ -1458,6 +1548,7 @@ class PhotosDB:
|
||||
|
||||
# get info to associate persons with photos
|
||||
# then get detected faces in each photo and link to persons
|
||||
verbose("Processing persons in photos.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
@@ -1523,6 +1614,7 @@ class PhotosDB:
|
||||
logging.debug(f"Unexpected KeyError _dbpersons_pk[{pk}]")
|
||||
|
||||
# get information on detected faces
|
||||
verbose("Processing detected faces in photos.")
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
ZPERSON.Z_PK,
|
||||
@@ -1557,6 +1649,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# get details about albums
|
||||
verbose("Processing albums.")
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
ZGENERICALBUM.ZUUID,
|
||||
@@ -1608,7 +1701,7 @@ class PhotosDB:
|
||||
for album in c:
|
||||
self._dbalbum_details[album[0]] = {
|
||||
"_uuid": album[0],
|
||||
"title": album[1],
|
||||
"title": normalize_unicode(album[1]),
|
||||
"cloudlocalstate": album[2],
|
||||
"cloudownerfirstname": album[3],
|
||||
"cloudownderlastname": album[4],
|
||||
@@ -1675,6 +1768,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbalbum_folders))
|
||||
|
||||
# get details on keywords
|
||||
verbose("Processing keywords.")
|
||||
c.execute(
|
||||
f"""SELECT ZKEYWORD.ZTITLE, {asset_table}.ZUUID
|
||||
FROM {asset_table}
|
||||
@@ -1683,12 +1777,13 @@ class PhotosDB:
|
||||
JOIN ZKEYWORD ON ZKEYWORD.Z_PK = {keyword_join} """
|
||||
)
|
||||
for keyword in c:
|
||||
keyword_title = normalize_unicode(keyword[0])
|
||||
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]] = []
|
||||
if not keyword_title in self._dbkeywords_keyword:
|
||||
self._dbkeywords_keyword[keyword_title] = []
|
||||
self._dbkeywords_uuid[keyword[1]].append(keyword[0])
|
||||
self._dbkeywords_keyword[keyword[0]].append(keyword[1])
|
||||
self._dbkeywords_keyword[keyword_title].append(keyword[1])
|
||||
|
||||
if _debug():
|
||||
logging.debug(f"Finished walking through keywords")
|
||||
@@ -1705,6 +1800,7 @@ class PhotosDB:
|
||||
logging.debug(self._dbvolumes)
|
||||
|
||||
# get details about photos
|
||||
verbose("Processing photo details.")
|
||||
logging.debug(f"Getting information about photos")
|
||||
c.execute(
|
||||
f"""SELECT {asset_table}.ZUUID,
|
||||
@@ -1743,7 +1839,8 @@ class PhotosDB:
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALWIDTH,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
|
||||
ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE,
|
||||
{depth_state}
|
||||
{depth_state},
|
||||
{asset_table}.ZADJUSTMENTTIMESTAMP
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
ORDER BY {asset_table}.ZUUID """
|
||||
@@ -1787,6 +1884,7 @@ class PhotosDB:
|
||||
# 34 ZADDITIONALASSETATTRIBUTES.ZORIGINALORIENTATION,
|
||||
# 35 ZADDITIONALASSETATTRIBUTES.ZORIGINALFILESIZE
|
||||
# 36 ZGENERICASSET.ZDEPTHSTATES / ZASSET.ZDEPTHTYPE
|
||||
# 37 ZGENERICASSET.ZADJUSTMENTTIMESTAMP -- when was photo edited?
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
@@ -1795,14 +1893,14 @@ class PhotosDB:
|
||||
info["modelID"] = None
|
||||
info["masterUuid"] = None
|
||||
info["masterFingerprint"] = row[1]
|
||||
info["name"] = row[2]
|
||||
info["name"] = normalize_unicode(row[2])
|
||||
|
||||
# There are sometimes negative values for lastmodifieddate in the database
|
||||
# I don't know what these mean but they will raise exception in datetime if
|
||||
# not accounted for
|
||||
info["lastmodifieddate_timestamp"] = row[4]
|
||||
info["lastmodifieddate_timestamp"] = row[37]
|
||||
try:
|
||||
info["lastmodifieddate"] = datetime.fromtimestamp(row[4] + TIME_DELTA)
|
||||
info["lastmodifieddate"] = datetime.fromtimestamp(row[37] + TIME_DELTA)
|
||||
except ValueError:
|
||||
info["lastmodifieddate"] = None
|
||||
except TypeError:
|
||||
@@ -1863,6 +1961,7 @@ class PhotosDB:
|
||||
info["type"] = None
|
||||
|
||||
info["UTI"] = row[18]
|
||||
info["UTI_original"] = None # filled in later
|
||||
|
||||
# handle burst photos
|
||||
# if burst photo, determine whether or not it's a selected burst photo
|
||||
@@ -1994,6 +2093,7 @@ class PhotosDB:
|
||||
# 1 ZGENERICASSET.ZIMPORTSESSION
|
||||
# 2 ZGENERICASSET.Z_FOK_IMPORTSESSION
|
||||
# 3 ZGENERICALBUM.ZUUID,
|
||||
verbose("Processing import sessions.")
|
||||
c.execute(
|
||||
f"""SELECT
|
||||
{asset_table}.ZUUID,
|
||||
@@ -2016,6 +2116,7 @@ class PhotosDB:
|
||||
logging.debug(f"No info record for uuid {uuid} for import session")
|
||||
|
||||
# Get extended description
|
||||
verbose("Processing additional photo details.")
|
||||
c.execute(
|
||||
f"""SELECT {asset_table}.ZUUID,
|
||||
ZASSETDESCRIPTION.ZLONGDESCRIPTION
|
||||
@@ -2027,7 +2128,7 @@ class PhotosDB:
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
if uuid in self._dbphotos:
|
||||
self._dbphotos[uuid]["extendedDescription"] = row[1]
|
||||
self._dbphotos[uuid]["extendedDescription"] = normalize_unicode(row[1])
|
||||
else:
|
||||
if _debug():
|
||||
logging.debug(
|
||||
@@ -2059,36 +2160,43 @@ class PhotosDB:
|
||||
# determine if a photo is missing in Photos 5
|
||||
|
||||
# Get info on remote/local availability for photos in shared albums
|
||||
# Also get UTI of original image (zdatastoresubtype = 1)
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
{asset_table}.ZUUID,
|
||||
ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
|
||||
ZINTERNALRESOURCE.ZREMOTEAVAILABILITY
|
||||
ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
|
||||
ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
|
||||
ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER,
|
||||
ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER
|
||||
FROM {asset_table}
|
||||
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
|
||||
JOIN ZINTERNALRESOURCE ON ZINTERNALRESOURCE.ZASSET = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
JOIN ZUNIFORMTYPEIDENTIFIER ON ZUNIFORMTYPEIDENTIFIER.Z_PK = ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER
|
||||
WHERE ZDATASTORESUBTYPE = 1 OR ZDATASTORESUBTYPE = 3 """
|
||||
)
|
||||
|
||||
# Order of results:
|
||||
# 0 {asset_table}.ZUUID,
|
||||
# 1 ZINTERNALRESOURCE.ZLOCALAVAILABILITY,
|
||||
# 2 ZINTERNALRESOURCE.ZREMOTEAVAILABILITY,
|
||||
# 3 ZINTERNALRESOURCE.ZDATASTORESUBTYPE,
|
||||
# 4 ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER,
|
||||
# 5 ZUNIFORMTYPEIDENTIFIER.ZIDENTIFIER
|
||||
|
||||
for row in c:
|
||||
uuid = row[0]
|
||||
if uuid in self._dbphotos:
|
||||
# and self._dbphotos[uuid]["isMissing"] is None:
|
||||
self._dbphotos[uuid]["localAvailability"] = row[1]
|
||||
self._dbphotos[uuid]["remoteAvailability"] = row[2]
|
||||
|
||||
# old = self._dbphotos[uuid]["isMissing"]
|
||||
if row[3] == 1:
|
||||
self._dbphotos[uuid]["UTI_original"] = row[5]
|
||||
|
||||
if row[1] != 1:
|
||||
self._dbphotos[uuid]["isMissing"] = 1
|
||||
else:
|
||||
self._dbphotos[uuid]["isMissing"] = 0
|
||||
|
||||
# if old is not None and old != self._dbphotos[uuid]["isMissing"]:
|
||||
# logging.warning(
|
||||
# f"{uuid} isMissing changed: {old} {self._dbphotos[uuid]['isMissing']}"
|
||||
# )
|
||||
|
||||
# get information on local/remote availability
|
||||
c.execute(
|
||||
f""" SELECT {asset_table}.ZUUID,
|
||||
@@ -2105,18 +2213,11 @@ class PhotosDB:
|
||||
self._dbphotos[uuid]["localAvailability"] = row[1]
|
||||
self._dbphotos[uuid]["remoteAvailability"] = row[2]
|
||||
|
||||
# old = self._dbphotos[uuid]["isMissing"]
|
||||
|
||||
if row[1] != 1:
|
||||
self._dbphotos[uuid]["isMissing"] = 1
|
||||
else:
|
||||
self._dbphotos[uuid]["isMissing"] = 0
|
||||
|
||||
# if old is not None and old != self._dbphotos[uuid]["isMissing"]:
|
||||
# logging.warning(
|
||||
# f"{uuid} isMissing changed: {old} {self._dbphotos[uuid]['isMissing']}"
|
||||
# )
|
||||
|
||||
# get information about cloud sync state
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
@@ -2195,18 +2296,27 @@ class PhotosDB:
|
||||
conn.close()
|
||||
|
||||
# process face info
|
||||
verbose("Processing face details.")
|
||||
self._process_faceinfo()
|
||||
|
||||
# process search info
|
||||
verbose("Processing photo labels.")
|
||||
self._process_searchinfo()
|
||||
|
||||
# process exif info
|
||||
verbose("Processing EXIF details.")
|
||||
self._process_exifinfo()
|
||||
|
||||
# process computed scores
|
||||
verbose("Processing computed aesthetic scores.")
|
||||
self._process_scoreinfo()
|
||||
|
||||
# process shared comments/likes
|
||||
verbose("Processing comments and likes for shared photos.")
|
||||
self._process_comments()
|
||||
|
||||
# done processing, dump debug data if requested
|
||||
verbose("Done processing details from Photos library.")
|
||||
if _debug():
|
||||
logging.debug("Faces (_dbfaces_uuid):")
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
@@ -11,6 +11,9 @@ from collections import namedtuple # pylint: disable=syntax-error
|
||||
import yaml
|
||||
from bpylist import archiver
|
||||
|
||||
from ._constants import UNICODE_FORMAT
|
||||
from .utils import normalize_unicode
|
||||
|
||||
# postal address information, returned by PlaceInfo.address
|
||||
PostalAddress = namedtuple(
|
||||
"PostalAddress",
|
||||
@@ -76,12 +79,12 @@ class PLRevGeoLocationInfo:
|
||||
geoServiceProvider,
|
||||
postalAddress,
|
||||
):
|
||||
self.addressString = addressString
|
||||
self.addressString = normalize_unicode(addressString)
|
||||
self.countryCode = countryCode
|
||||
self.mapItem = mapItem
|
||||
self.isHome = isHome
|
||||
self.compoundNames = compoundNames
|
||||
self.compoundSecondaryNames = compoundSecondaryNames
|
||||
self.compoundNames = normalize_unicode(compoundNames)
|
||||
self.compoundSecondaryNames = normalize_unicode(compoundSecondaryNames)
|
||||
self.version = version
|
||||
self.geoServiceProvider = geoServiceProvider
|
||||
self.postalAddress = postalAddress
|
||||
@@ -183,7 +186,7 @@ class PLRevGeoMapItemAdditionalPlaceInfo:
|
||||
|
||||
def __init__(self, area, name, placeType, dominantOrderType):
|
||||
self.area = area
|
||||
self.name = name
|
||||
self.name = normalize_unicode(name)
|
||||
self.placeType = placeType
|
||||
self.dominantOrderType = dominantOrderType
|
||||
|
||||
@@ -232,13 +235,13 @@ class CNPostalAddress:
|
||||
_subLocality,
|
||||
):
|
||||
self._ISOCountryCode = _ISOCountryCode
|
||||
self._city = _city
|
||||
self._country = _country
|
||||
self._postalCode = _postalCode
|
||||
self._state = _state
|
||||
self._street = _street
|
||||
self._subAdministrativeArea = _subAdministrativeArea
|
||||
self._subLocality = _subLocality
|
||||
self._city = normalize_unicode(_city)
|
||||
self._country = normalize_unicode(_country)
|
||||
self._postalCode = normalize_unicode(_postalCode)
|
||||
self._state = normalize_unicode(_state)
|
||||
self._street = normalize_unicode(_street)
|
||||
self._subAdministrativeArea = normalize_unicode(_subAdministrativeArea)
|
||||
self._subLocality = normalize_unicode(_subLocality)
|
||||
|
||||
def __eq__(self, other):
|
||||
return all(
|
||||
@@ -414,9 +417,9 @@ class PlaceInfo4(PlaceInfo):
|
||||
# 2: type
|
||||
# 3: area
|
||||
try:
|
||||
places_dict[p[2]].append((p[1], p[3]))
|
||||
places_dict[p[2]].append((normalize_unicode(p[1]), p[3]))
|
||||
except KeyError:
|
||||
places_dict[p[2]] = [(p[1], p[3])]
|
||||
places_dict[p[2]] = [(normalize_unicode(p[1]), p[3])]
|
||||
|
||||
# build list to populate PlaceNames tuple
|
||||
# initialize with empty lists for each field in PlaceNames
|
||||
@@ -488,7 +491,7 @@ class PlaceInfo4(PlaceInfo):
|
||||
}
|
||||
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
|
||||
def as_dict(self):
|
||||
def asdict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"names": self.names._asdict(),
|
||||
@@ -631,7 +634,7 @@ class PlaceInfo5(PlaceInfo):
|
||||
}
|
||||
return "PlaceInfo(" + ", ".join([f"{k}='{v}'" for k, v in info.items()]) + ")"
|
||||
|
||||
def as_dict(self):
|
||||
def asdict(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"names": self.names._asdict(),
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
|
||||
|
||||
<%def name="photoshop_sidecar_for_extension(extension)">
|
||||
% if extension is None:
|
||||
<photoshop:SidecarForExtension></photoshop:SidecarForExtension>
|
||||
% else:
|
||||
<photoshop:SidecarForExtension>${extension}</photoshop:SidecarForExtension>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
<%def name="dc_description(desc)">
|
||||
% if desc is None:
|
||||
<dc:description></dc:description>
|
||||
% else:
|
||||
<dc:description>${desc}</dc:description>
|
||||
<dc:description>${desc | x}</dc:description>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
@@ -12,17 +20,16 @@
|
||||
% if title is None:
|
||||
<dc:title></dc:title>
|
||||
% else:
|
||||
<dc:title>${title}</dc:title>
|
||||
<dc:title>${title | x}</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>
|
||||
<rdf:li>${subj | x}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Seq>
|
||||
</dc:subject>
|
||||
@@ -40,7 +47,7 @@
|
||||
<Iptc4xmpExt:PersonInImage>
|
||||
<rdf:Bag>
|
||||
% for person in persons:
|
||||
<rdf:li>${person}</rdf:li>
|
||||
<rdf:li>${person | x}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Bag>
|
||||
</Iptc4xmpExt:PersonInImage>
|
||||
@@ -52,7 +59,7 @@
|
||||
<digiKam:TagsList>
|
||||
<rdf:Seq>
|
||||
% for keyword in keywords:
|
||||
<rdf:li>${keyword}</rdf:li>
|
||||
<rdf:li>${keyword | x}</rdf:li>
|
||||
% endfor
|
||||
</rdf:Seq>
|
||||
</digiKam:TagsList>
|
||||
@@ -73,10 +80,8 @@
|
||||
|
||||
<%def name="gps_info(latitude, longitude)">
|
||||
% if latitude is not None and longitude is not None:
|
||||
<exif:GPSLongitudeRef>${"E" if longitude >= 0 else "W"}</exif:GPSLongitudeRef>
|
||||
<exif:GPSLongitude>${abs(longitude)}</exif:GPSLongitude>
|
||||
<exif:GPSLatitude>${abs(latitude)}</exif:GPSLatitude>
|
||||
<exif:GPSLatitudeRef>${"N" if latitude >= 0 else "S"}</exif:GPSLatitudeRef>
|
||||
<exif:GPSLongitude>${int(abs(longitude))},${(abs(longitude) % 1) * 60}${"E" if longitude >= 0 else "W"}</exif:GPSLongitude>
|
||||
<exif:GPSLatitude>${int(abs(latitude))},${(abs(latitude) % 1) * 60}${"N" if latitude >= 0 else "S"}</exif:GPSLatitude>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
@@ -86,6 +91,7 @@
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
|
||||
${photoshop_sidecar_for_extension(extension)}
|
||||
${dc_description(description)}
|
||||
${dc_title(photo.title)}
|
||||
${dc_subject(subjects)}
|
||||
|
||||
@@ -10,14 +10,15 @@ import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from plistlib import load as plistload
|
||||
|
||||
import CoreFoundation
|
||||
import CoreServices
|
||||
import objc
|
||||
from Foundation import *
|
||||
|
||||
from ._constants import UNICODE_FORMAT
|
||||
from .fileutil import FileUtil
|
||||
|
||||
_DEBUG = False
|
||||
@@ -56,6 +57,11 @@ def _debug():
|
||||
return _DEBUG
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
""" do nothing (no operation) """
|
||||
pass
|
||||
|
||||
|
||||
def _get_os_version():
|
||||
# returns tuple containing OS version
|
||||
# e.g. 10.13.6 = (10, 13, 6)
|
||||
@@ -195,7 +201,7 @@ def get_last_library_path():
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=undefined-variable
|
||||
photosurl = CoreFoundation.CFURLCreateByResolvingBookmarkData(
|
||||
kCFAllocatorDefault, photosurlref, 0, None, None, None, None
|
||||
CoreFoundation.kCFAllocatorDefault, photosurlref, 0, None, None, None, None
|
||||
)
|
||||
|
||||
# the CFURLRef we got is a sruct that python treats as an array
|
||||
@@ -352,3 +358,39 @@ def _db_is_locked(dbname):
|
||||
# attr = xattr.xattr(filepath)
|
||||
# uuid_bytes = bytes(uuid, 'utf-8')
|
||||
# attr.set(OSXPHOTOS_XATTR_UUID, uuid_bytes)
|
||||
|
||||
|
||||
def normalize_unicode(value):
|
||||
""" normalize unicode data """
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("value must be str")
|
||||
return unicodedata.normalize(UNICODE_FORMAT, value)
|
||||
|
||||
|
||||
def increment_filename(filepath):
|
||||
""" Return filename (1).ext, etc if filename.ext exists
|
||||
|
||||
If file exists in filename's parent folder with same stem as filename,
|
||||
add (1), (2), etc. until a non-existing filename is found.
|
||||
|
||||
Args:
|
||||
filepath: str; full path, including file name
|
||||
|
||||
Returns:
|
||||
new filepath (or same if not incremented)
|
||||
|
||||
Note: This obviously is subject to race condition so using with caution.
|
||||
"""
|
||||
dest = pathlib.Path(str(filepath))
|
||||
count = 1
|
||||
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
|
||||
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
while dest_new.lower() in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
return str(dest)
|
||||
|
||||
@@ -42,11 +42,13 @@ mccabe==0.6.1
|
||||
modulegraph==0.18
|
||||
more-itertools==7.2.0
|
||||
multidict==4.7.6
|
||||
osxmetadata>=0.99.11
|
||||
packaging==19.0
|
||||
parso==0.6.2
|
||||
pathspec==0.7.0
|
||||
pathvalidate==2.2.1
|
||||
pexpect==4.8.0
|
||||
photoscript==0.1.0
|
||||
pickleshare==0.7.5
|
||||
Pillow==7.2.0
|
||||
pkginfo==1.5.0.1
|
||||
@@ -202,5 +204,6 @@ virtualenv==20.0.30
|
||||
wcwidth==0.1.9
|
||||
webencodings==0.5.1
|
||||
wrapt==1.11.1
|
||||
wurlitzer==2.0.1
|
||||
yarl==1.4.2
|
||||
zipp==0.5.2
|
||||
zipp==0.5.2
|
||||
4
setup.py
@@ -78,6 +78,10 @@ setup(
|
||||
"bpylist2==3.0.2",
|
||||
"pathvalidate==2.2.1",
|
||||
"dataclasses==0.7;python_version<'3.7'",
|
||||
"wurlitzer>=2.0.1",
|
||||
"photoscript>=0.1.0",
|
||||
"toml>=0.10.0",
|
||||
"osxmetadata>=0.99.13",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
|
||||
BIN
tests/Test-10.14.6.photoslibrary/Masters/2020/10/05/20201005-041506/IMG_1997.JPG
Executable file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
tests/Test-10.14.6.photoslibrary/Masters/2020/10/05/20201005-041506/IMG_1997.cr2
Executable file
BIN
tests/Test-10.14.6.photoslibrary/Masters/2020/10/05/20201005-041514/IMG_1994.JPG
Executable file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
tests/Test-10.14.6.photoslibrary/Masters/2020/10/05/20201005-041514/IMG_1994.cr2
Executable file
BIN
tests/Test-10.14.6.photoslibrary/Masters/2020/10/05/20201005-041542/DSC03584.dng
Executable file
|
After Width: | Height: | Size: 1.9 MiB |
@@ -36,7 +36,7 @@
|
||||
<key>other</key>
|
||||
<integer>0</integer>
|
||||
<key>photos</key>
|
||||
<integer>6</integer>
|
||||
<integer>11</integer>
|
||||
<key>videos</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-07-27T03:16:28Z</date>
|
||||
<date>2020-11-01T02:34:49Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-07-27T12:35:43Z</date>
|
||||
<date>2020-11-01T02:34:49Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>LithiumMessageTracer</key>
|
||||
<dict>
|
||||
<key>LastReportedDate</key>
|
||||
<date>2020-07-27T03:18:40Z</date>
|
||||
<date>2020-10-04T23:49:39Z</date>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-07-27T03:16:25Z</date>
|
||||
<date>2020-11-01T02:34:46Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>707</integer>
|
||||
<integer>948</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
|
||||
|
Before Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 465 KiB |
@@ -9,7 +9,7 @@
|
||||
<key>HistoricalMarker</key>
|
||||
<dict>
|
||||
<key>LastHistoryRowId</key>
|
||||
<integer>707</integer>
|
||||
<integer>948</integer>
|
||||
<key>LibraryBuildTag</key>
|
||||
<string>D8C4AAA1-3AB6-4A65-BEBD-99CC3E5D433E</string>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
@@ -24,7 +24,7 @@
|
||||
<key>SnapshotCompletedDate</key>
|
||||
<date>2019-07-27T13:16:43Z</date>
|
||||
<key>SnapshotLastValidated</key>
|
||||
<date>2020-07-27T03:18:40Z</date>
|
||||
<date>2020-11-01T02:34:46Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>1847</integer>
|
||||
<integer>36387</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||