Compare commits
187 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
124
.all-contributorsrc
Normal file
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
|
||||
}
|
||||
393
CHANGELOG.md
393
CHANGELOG.md
@@ -4,6 +4,399 @@ 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.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
|
||||
|
||||
156
examples/get_shared_photo_comments.py
Normal file
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
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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -102,3 +102,84 @@ _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
|
||||
@@ -1,4 +1,5 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.35.3"
|
||||
__version__ = "0.38.21"
|
||||
|
||||
|
||||
|
||||
173
osxphotos/configoptions.py
Normal file
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:
|
||||
@@ -52,14 +49,14 @@ class _ExifToolProc:
|
||||
|
||||
if hasattr(self, "_process_running") and self._process_running:
|
||||
# already running
|
||||
if exiftool is not None:
|
||||
if 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._exiftool = exiftool or get_exiftool_path()
|
||||
self._process_running = False
|
||||
self._start_proc()
|
||||
|
||||
@@ -98,12 +95,12 @@ 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 (possible interfere w/ --touch-file)
|
||||
"-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
|
||||
|
||||
@@ -135,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
|
||||
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")
|
||||
@@ -178,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
|
||||
@@ -204,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):
|
||||
@@ -225,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):
|
||||
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]
|
||||
@@ -241,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 = "2.0"
|
||||
OSXPHOTOS_EXPORTDB_VERSION = "3.2"
|
||||
|
||||
|
||||
class ExportDB_ABC(ABC):
|
||||
@@ -76,6 +76,14 @@ class ExportDB_ABC(ABC):
|
||||
def set_exifdata_for_file(self, uuid, exifdata):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
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,
|
||||
@@ -141,6 +149,12 @@ class ExportDBNoOp(ExportDB_ABC):
|
||||
def set_exifdata_for_file(self, uuid, exifdata):
|
||||
pass
|
||||
|
||||
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,
|
||||
@@ -379,6 +393,48 @@ class ExportDB(ExportDB_ABC):
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
@@ -479,13 +535,11 @@ class ExportDB(ExportDB_ABC):
|
||||
|
||||
if not os.path.isfile(dbfile):
|
||||
conn = self._get_db_connection(dbfile)
|
||||
if conn:
|
||||
self._create_db_tables(conn)
|
||||
self.was_created = True
|
||||
self.was_upgraded = ()
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
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:
|
||||
conn = self._get_db_connection(dbfile)
|
||||
self.was_created = False
|
||||
@@ -495,8 +549,7 @@ class ExportDB(ExportDB_ABC):
|
||||
self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION)
|
||||
else:
|
||||
self.was_upgraded = ()
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
return conn
|
||||
|
||||
def _get_db_connection(self, dbfile):
|
||||
@@ -570,11 +623,20 @@ class ExportDB(ExportDB_ABC):
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
""" FileUtil class with methods for copy, hardlink, unlink, etc. """
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
@@ -8,8 +7,11 @@ import subprocess
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import CoreFoundation
|
||||
|
||||
from .imageconverter import ImageConverter
|
||||
|
||||
|
||||
class FileUtilABC(ABC):
|
||||
""" Abstract base class for FileUtil """
|
||||
|
||||
@@ -28,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):
|
||||
@@ -74,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):
|
||||
@@ -119,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. """
|
||||
@@ -169,7 +183,7 @@ class FileUtilMacOS(FileUtilABC):
|
||||
def file_sig(cls, f1):
|
||||
""" 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
|
||||
@@ -183,7 +197,9 @@ class FileUtilMacOS(FileUtilABC):
|
||||
True if success, otherwise False
|
||||
"""
|
||||
converter = ImageConverter()
|
||||
return converter.write_jpeg(src_file, dest_file, compression_quality=compression_quality)
|
||||
return converter.write_jpeg(
|
||||
src_file, dest_file, compression_quality=compression_quality
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sig(st):
|
||||
@@ -194,6 +210,7 @@ class FileUtilMacOS(FileUtilABC):
|
||||
# use int(st.st_mtime) because ditto does not copy fractional portion of mtime
|
||||
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
|
||||
|
||||
|
||||
class FileUtil(FileUtilMacOS):
|
||||
""" Various file utilities """
|
||||
|
||||
@@ -233,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}")
|
||||
|
||||
79
osxphotos/path_utils.py
Normal file
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
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -20,11 +21,11 @@ from .._constants import (
|
||||
_PHOTOS_4_ALBUM_KIND,
|
||||
_PHOTOS_4_ROOT_FOLDER,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_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
|
||||
@@ -42,6 +43,7 @@ class PhotoInfo:
|
||||
# import additional methods
|
||||
from ._photoinfo_searchinfo import (
|
||||
search_info,
|
||||
search_info_normalized,
|
||||
labels,
|
||||
labels_normalized,
|
||||
SearchInfo,
|
||||
@@ -52,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
|
||||
@@ -68,7 +74,11 @@ class PhotoInfo:
|
||||
@property
|
||||
def filename(self):
|
||||
""" filename of the picture """
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION and self.has_raw and self.raw_original:
|
||||
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:
|
||||
@@ -78,11 +88,16 @@ class PhotoInfo:
|
||||
def original_filename(self):
|
||||
""" 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:
|
||||
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"]["originalFilename"]
|
||||
original_name = self._info["raw_pair_info"]["originalFilename"]
|
||||
else:
|
||||
return self._info["originalFilename"]
|
||||
original_name = self._info["originalFilename"]
|
||||
return original_name or self.filename
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
@@ -93,12 +108,20 @@ class PhotoInfo:
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -145,6 +168,8 @@ class PhotoInfo:
|
||||
photopath = os.path.join(
|
||||
self._db._masters_path, self._info["imagePath"]
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
return photopath
|
||||
|
||||
@@ -156,6 +181,8 @@ class PhotoInfo:
|
||||
self._info["directory"],
|
||||
self._info["filename"],
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
return photopath
|
||||
|
||||
@@ -169,6 +196,8 @@ class PhotoInfo:
|
||||
self._info["directory"],
|
||||
self._info["filename"],
|
||||
)
|
||||
if not os.path.isfile(photopath):
|
||||
photopath = None
|
||||
self._path = photopath
|
||||
return photopath
|
||||
|
||||
@@ -177,18 +206,15 @@ class PhotoInfo:
|
||||
""" 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()
|
||||
return self._path_edited
|
||||
else:
|
||||
self._path_edited = self._path_edited_5()
|
||||
return self._path_edited
|
||||
|
||||
return self._path_edited
|
||||
|
||||
def _path_edited_5(self):
|
||||
""" return path_edited for Photos >= 5 """
|
||||
@@ -246,8 +272,6 @@ class PhotoInfo:
|
||||
# if self._info["isMissing"] == 1:
|
||||
# photopath = None # path would be meaningless until downloaded
|
||||
|
||||
# logging.debug(photopath)
|
||||
|
||||
return photopath
|
||||
|
||||
def _path_edited_4(self):
|
||||
@@ -280,7 +304,7 @@ class PhotoInfo:
|
||||
# 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,
|
||||
library, "resources", "media", "version", folder_id, "00", filename
|
||||
)
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
@@ -808,6 +832,9 @@ class PhotoInfo:
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
replacement=":",
|
||||
):
|
||||
"""Renders a template string for PhotoInfo instance using PhotoTemplate
|
||||
|
||||
@@ -820,6 +847,9 @@ class PhotoInfo:
|
||||
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
|
||||
replacement: str, value to replace any illegal file path characters with; default = ":"
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
@@ -831,6 +861,9 @@ class PhotoInfo:
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
replacement=replacement,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -940,22 +973,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,
|
||||
@@ -964,6 +999,7 @@ class PhotoInfo:
|
||||
"albums": self.albums,
|
||||
"folders": folders,
|
||||
"persons": self.persons,
|
||||
"faces": faces,
|
||||
"path": self.path,
|
||||
"ismissing": self.ismissing,
|
||||
"hasadjustments": self.hasadjustments,
|
||||
@@ -977,12 +1013,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,
|
||||
@@ -991,6 +1028,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,
|
||||
@@ -1004,8 +1043,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
1215
osxphotos/photokit.py
Normal file
File diff suppressed because it is too large
Load Diff
157
osxphotos/photosdb/_photosdb_process_comments.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()
|
||||
@@ -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"] = normalize_unicode(row[6].replace("\x00", ""))
|
||||
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"] = row[8]
|
||||
record["lookup_identifier"] = normalize_unicode(row[8].replace("\x00", ""))
|
||||
|
||||
try:
|
||||
_db_searchinfo_uuid[uuid].append(record)
|
||||
|
||||
@@ -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,7 @@ 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
|
||||
@@ -67,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()
|
||||
@@ -84,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_")
|
||||
@@ -216,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
|
||||
@@ -262,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)
|
||||
@@ -279,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():
|
||||
@@ -530,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():
|
||||
@@ -545,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,
|
||||
@@ -618,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,
|
||||
@@ -655,6 +692,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# Get info on albums
|
||||
verbose("Processing albums.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKAlbum.uuid,
|
||||
@@ -797,6 +835,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbfolder_details))
|
||||
|
||||
# Get info on keywords
|
||||
verbose("Processing keywords.")
|
||||
c.execute(
|
||||
""" SELECT
|
||||
RKKeyword.name,
|
||||
@@ -824,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(
|
||||
@@ -1113,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,
|
||||
@@ -1286,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 "
|
||||
@@ -1372,6 +1414,7 @@ class PhotosDB:
|
||||
conn.close()
|
||||
|
||||
# process faces
|
||||
verbose("Processing face details.")
|
||||
self._process_faceinfo()
|
||||
|
||||
# add faces and keywords to photo data
|
||||
@@ -1408,6 +1451,7 @@ class PhotosDB:
|
||||
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))
|
||||
@@ -1483,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"]
|
||||
@@ -1502,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,
|
||||
@@ -1567,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,
|
||||
@@ -1601,6 +1649,7 @@ class PhotosDB:
|
||||
logging.debug(pformat(self._dbfaces_uuid))
|
||||
|
||||
# get details about albums
|
||||
verbose("Processing albums.")
|
||||
c.execute(
|
||||
f""" SELECT
|
||||
ZGENERICALBUM.ZUUID,
|
||||
@@ -1719,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}
|
||||
@@ -1750,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,
|
||||
@@ -1788,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 """
|
||||
@@ -1832,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]
|
||||
@@ -1845,9 +1898,9 @@ class PhotosDB:
|
||||
# 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:
|
||||
@@ -1908,7 +1961,7 @@ class PhotosDB:
|
||||
info["type"] = None
|
||||
|
||||
info["UTI"] = row[18]
|
||||
info["UTI_original"] = None # filled in later
|
||||
info["UTI_original"] = None # filled in later
|
||||
|
||||
# handle burst photos
|
||||
# if burst photo, determine whether or not it's a selected burst photo
|
||||
@@ -2040,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,
|
||||
@@ -2062,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
|
||||
@@ -2241,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))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -491,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(),
|
||||
@@ -634,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(),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
% if desc is None:
|
||||
<dc:description></dc:description>
|
||||
% else:
|
||||
<dc:description>${desc}</dc:description>
|
||||
<dc:description>${desc | x}</dc:description>
|
||||
% endif
|
||||
</%def>
|
||||
|
||||
@@ -20,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>
|
||||
@@ -48,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>
|
||||
@@ -60,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>
|
||||
@@ -81,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>
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from plistlib import load as plistload
|
||||
import CoreFoundation
|
||||
import CoreServices
|
||||
import objc
|
||||
from Foundation import *
|
||||
|
||||
from ._constants import UNICODE_FORMAT
|
||||
from .fileutil import FileUtil
|
||||
@@ -58,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)
|
||||
@@ -197,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
|
||||
@@ -358,9 +362,35 @@ def _db_is_locked(dbname):
|
||||
|
||||
def normalize_unicode(value):
|
||||
""" normalize unicode data """
|
||||
if value is not None:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("value must be str")
|
||||
return unicodedata.normalize(UNICODE_FORMAT, value)
|
||||
else:
|
||||
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)
|
||||
|
||||
@@ -47,6 +47,7 @@ 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
|
||||
|
||||
2
setup.py
2
setup.py
@@ -79,6 +79,8 @@ setup(
|
||||
"pathvalidate==2.2.1",
|
||||
"dataclasses==0.7;python_version<'3.7'",
|
||||
"wurlitzer>=2.0.1",
|
||||
"photoscript>=0.1.0",
|
||||
"toml>=0.10.0",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
|
||||
Binary file not shown.
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-10-09T16:14:42Z</date>
|
||||
<date>2020-11-01T02:34:49Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-10-10T05:21:03Z</date>
|
||||
<date>2020-11-01T02:34:49Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,6 @@
|
||||
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
|
||||
<integer>1</integer>
|
||||
<key>PLLastRevGeoVerFileFetchDateKey</key>
|
||||
<date>2020-10-04T23:43:17Z</date>
|
||||
<date>2020-11-01T02:34:46Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<key>SnapshotCompletedDate</key>
|
||||
<date>2019-07-27T13:16:43Z</date>
|
||||
<key>SnapshotLastValidated</key>
|
||||
<date>2020-10-10T05:22:36Z</date>
|
||||
<date>2020-11-01T02:34:46Z</date>
|
||||
<key>SnapshotTables</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>2964</integer>
|
||||
<integer>36387</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,24 +3,24 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BackgroundHighlightCollection</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundHighlightEnrichment</key>
|
||||
<date>2020-06-24T04:02:12Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundJobAssetRevGeocode</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundJobSearch</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundPeopleSuggestion</key>
|
||||
<date>2020-06-24T04:02:12Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>BackgroundUserBehaviorProcessor</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphConsistencyUpdateJobDateKey</key>
|
||||
<date>2020-05-30T02:16:06Z</date>
|
||||
<date>2020-10-17T23:45:33Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundGraphRebuildJobDate</key>
|
||||
<date>2020-05-29T04:31:37Z</date>
|
||||
<date>2020-10-17T23:45:24Z</date>
|
||||
<key>PhotoAnalysisGraphLastBackgroundMemoryGenerationJobDate</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:26Z</date>
|
||||
<key>SiriPortraitDonation</key>
|
||||
<date>2020-06-24T04:02:13Z</date>
|
||||
<date>2020-10-17T23:45:25Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NumberOfFacesProcessedOnLastRun</key>
|
||||
<integer>7</integer>
|
||||
<integer>11</integer>
|
||||
<key>ProcessedInQuiescentState</key>
|
||||
<true/>
|
||||
<key>SuggestedMeIdentifier</key>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>FaceIDModelLastGenerationKey</key>
|
||||
<date>2020-05-29T03:44:04Z</date>
|
||||
<date>2020-10-17T23:45:32Z</date>
|
||||
<key>LastContactClassificationKey</key>
|
||||
<date>2020-05-29T04:31:40Z</date>
|
||||
<date>2020-10-17T23:45:54Z</date>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>LibrarySchemaVersion</key>
|
||||
<integer>5001</integer>
|
||||
<key>MetaSchemaVersion</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite
Normal file
Binary file not shown.
BIN
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-shm
Normal file
Binary file not shown.
BIN
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite-wal
Normal file
Binary file not shown.
16
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock
Normal file
16
tests/Test-10.15.7.photoslibrary/database/Photos.sqlite.lock
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>hostname</key>
|
||||
<string>Rhets-MacBook-Pro.local</string>
|
||||
<key>hostuuid</key>
|
||||
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
|
||||
<key>pid</key>
|
||||
<integer>55247</integer>
|
||||
<key>processname</key>
|
||||
<string>photolibraryd</string>
|
||||
<key>uid</key>
|
||||
<integer>501</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
tests/Test-10.15.7.photoslibrary/database/metaSchema.db
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/metaSchema.db
Normal file
Binary file not shown.
BIN
tests/Test-10.15.7.photoslibrary/database/photos.db
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/photos.db
Normal file
Binary file not shown.
Binary file not shown.
BIN
tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite
Normal file
Binary file not shown.
BIN
tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite-shm
Normal file
BIN
tests/Test-10.15.7.photoslibrary/database/search/psi.sqlite-shm
Normal file
Binary file not shown.
@@ -0,0 +1,188 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BlacklistedMeaningsByMeaning</key>
|
||||
<dict/>
|
||||
<key>MePersonUUID</key>
|
||||
<string>39488755-78C0-40B2-B378-EDA280E1823C</string>
|
||||
<key>SceneWhitelist</key>
|
||||
<array>
|
||||
<string>Graduation</string>
|
||||
<string>Aquarium</string>
|
||||
<string>Food</string>
|
||||
<string>Ice Skating</string>
|
||||
<string>Mountain</string>
|
||||
<string>Cliff</string>
|
||||
<string>Basketball</string>
|
||||
<string>Tennis</string>
|
||||
<string>Jewelry</string>
|
||||
<string>Cheese</string>
|
||||
<string>Softball</string>
|
||||
<string>Football</string>
|
||||
<string>Circus</string>
|
||||
<string>Jet Ski</string>
|
||||
<string>Playground</string>
|
||||
<string>Carousel</string>
|
||||
<string>Paint Ball</string>
|
||||
<string>Windsurfing</string>
|
||||
<string>Sailboat</string>
|
||||
<string>Sunbathing</string>
|
||||
<string>Dam</string>
|
||||
<string>Fireplace</string>
|
||||
<string>Flower</string>
|
||||
<string>Scuba</string>
|
||||
<string>Hiking</string>
|
||||
<string>Cetacean</string>
|
||||
<string>Pier</string>
|
||||
<string>Bowling</string>
|
||||
<string>Snowboarding</string>
|
||||
<string>Zoo</string>
|
||||
<string>Snowmobile</string>
|
||||
<string>Theater</string>
|
||||
<string>Boat</string>
|
||||
<string>Casino</string>
|
||||
<string>Car</string>
|
||||
<string>Diving</string>
|
||||
<string>Cycling</string>
|
||||
<string>Musical Instrument</string>
|
||||
<string>Board Game</string>
|
||||
<string>Castle</string>
|
||||
<string>Sunset Sunrise</string>
|
||||
<string>Martial Arts</string>
|
||||
<string>Motocross</string>
|
||||
<string>Submarine</string>
|
||||
<string>Cat</string>
|
||||
<string>Snow</string>
|
||||
<string>Kiteboarding</string>
|
||||
<string>Squash</string>
|
||||
<string>Geyser</string>
|
||||
<string>Music</string>
|
||||
<string>Archery</string>
|
||||
<string>Desert</string>
|
||||
<string>Blackjack</string>
|
||||
<string>Fireworks</string>
|
||||
<string>Sportscar</string>
|
||||
<string>Feline</string>
|
||||
<string>Soccer</string>
|
||||
<string>Museum</string>
|
||||
<string>Baby</string>
|
||||
<string>Fencing</string>
|
||||
<string>Railroad</string>
|
||||
<string>Nascar</string>
|
||||
<string>Sky Surfing</string>
|
||||
<string>Bird</string>
|
||||
<string>Games</string>
|
||||
<string>Baseball</string>
|
||||
<string>Dressage</string>
|
||||
<string>Snorkeling</string>
|
||||
<string>Pyramid</string>
|
||||
<string>Kite</string>
|
||||
<string>Rowboat</string>
|
||||
<string>Golf</string>
|
||||
<string>Watersports</string>
|
||||
<string>Lightning</string>
|
||||
<string>Canyon</string>
|
||||
<string>Auditorium</string>
|
||||
<string>Night Sky</string>
|
||||
<string>Karaoke</string>
|
||||
<string>Skiing</string>
|
||||
<string>Parade</string>
|
||||
<string>Forest</string>
|
||||
<string>Hot Air Balloon</string>
|
||||
<string>Dragon Parade</string>
|
||||
<string>Easter Egg</string>
|
||||
<string>Monument</string>
|
||||
<string>Jungle</string>
|
||||
<string>Thanksgiving</string>
|
||||
<string>Jockey Horse</string>
|
||||
<string>Stadium</string>
|
||||
<string>Airplane</string>
|
||||
<string>Ballet</string>
|
||||
<string>Yoga</string>
|
||||
<string>Coral Reef</string>
|
||||
<string>Skating</string>
|
||||
<string>Wrestling</string>
|
||||
<string>Bicycle</string>
|
||||
<string>Tattoo</string>
|
||||
<string>Amusement Park</string>
|
||||
<string>Canoe</string>
|
||||
<string>Cheerleading</string>
|
||||
<string>Ping Pong</string>
|
||||
<string>Fishing</string>
|
||||
<string>Magic</string>
|
||||
<string>Reptile</string>
|
||||
<string>Winter Sport</string>
|
||||
<string>Waterfall</string>
|
||||
<string>Train</string>
|
||||
<string>Bonsai</string>
|
||||
<string>Surfing</string>
|
||||
<string>Dog</string>
|
||||
<string>Cake</string>
|
||||
<string>Sledding</string>
|
||||
<string>Sandcastle</string>
|
||||
<string>Glacier</string>
|
||||
<string>Lighthouse</string>
|
||||
<string>Equestrian</string>
|
||||
<string>Rafting</string>
|
||||
<string>Shore</string>
|
||||
<string>Hockey</string>
|
||||
<string>Santa Claus</string>
|
||||
<string>Formula One Car</string>
|
||||
<string>Sport</string>
|
||||
<string>Vehicle</string>
|
||||
<string>Boxing</string>
|
||||
<string>Rollerskating</string>
|
||||
<string>Underwater</string>
|
||||
<string>Orchestra</string>
|
||||
<string>Carnival</string>
|
||||
<string>Rocket</string>
|
||||
<string>Skateboarding</string>
|
||||
<string>Helicopter</string>
|
||||
<string>Performance</string>
|
||||
<string>Oktoberfest</string>
|
||||
<string>Water Polo</string>
|
||||
<string>Skate Park</string>
|
||||
<string>Animal</string>
|
||||
<string>Nightclub</string>
|
||||
<string>String Instrument</string>
|
||||
<string>Dinosaur</string>
|
||||
<string>Gymnastics</string>
|
||||
<string>Cricket</string>
|
||||
<string>Volcano</string>
|
||||
<string>Lake</string>
|
||||
<string>Aurora</string>
|
||||
<string>Dancing</string>
|
||||
<string>Concert</string>
|
||||
<string>Rock Climbing</string>
|
||||
<string>Hang Glider</string>
|
||||
<string>Rodeo</string>
|
||||
<string>Fish</string>
|
||||
<string>Art</string>
|
||||
<string>Motorcycle</string>
|
||||
<string>Volleyball</string>
|
||||
<string>Wake Boarding</string>
|
||||
<string>Badminton</string>
|
||||
<string>Motor Sport</string>
|
||||
<string>Sumo</string>
|
||||
<string>Parasailing</string>
|
||||
<string>Skydiving</string>
|
||||
<string>Kickboxing</string>
|
||||
<string>Pinata</string>
|
||||
<string>Foosball</string>
|
||||
<string>Go Kart</string>
|
||||
<string>Poker</string>
|
||||
<string>Kayak</string>
|
||||
<string>Swimming</string>
|
||||
<string>Atv</string>
|
||||
<string>Beach</string>
|
||||
<string>Dartboard</string>
|
||||
<string>Athletics</string>
|
||||
<string>Camping</string>
|
||||
<string>Tornado</string>
|
||||
<string>Billiards</string>
|
||||
<string>Rugby</string>
|
||||
<string>Airshow</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>insertAlbum</key>
|
||||
<array/>
|
||||
<key>insertAsset</key>
|
||||
<array/>
|
||||
<key>insertHighlight</key>
|
||||
<array/>
|
||||
<key>insertMemory</key>
|
||||
<array/>
|
||||
<key>insertMoment</key>
|
||||
<array/>
|
||||
<key>removeAlbum</key>
|
||||
<array/>
|
||||
<key>removeAsset</key>
|
||||
<array/>
|
||||
<key>removeHighlight</key>
|
||||
<array/>
|
||||
<key>removeMemory</key>
|
||||
<array/>
|
||||
<key>removeMoment</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>embeddingVersion</key>
|
||||
<string>1</string>
|
||||
<key>localeIdentifier</key>
|
||||
<string>en_US</string>
|
||||
<key>sceneTaxonomySHA</key>
|
||||
<string>87914a047c69fbe8013fad2c70fa70c6c03b08b56190fe4054c880e6b9f57cc3</string>
|
||||
<key>searchIndexVersion</key>
|
||||
<string>10</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 577 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user