Compare commits
510 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feb9538d1c | ||
|
|
b275280a1f | ||
|
|
924ef72446 | ||
|
|
c95f682ca6 | ||
|
|
d2753672f3 | ||
|
|
8ee4ea46c5 | ||
|
|
6fae979061 | ||
|
|
25d6f148be | ||
|
|
3704fc4a23 | ||
|
|
7883fc1911 | ||
|
|
29ff7f8666 | ||
|
|
43e1cb18cc | ||
|
|
26f916e4cb | ||
|
|
4e9e877b27 | ||
|
|
3a990e3997 | ||
|
|
4d1b1db2a7 | ||
|
|
173e3ccc37 | ||
|
|
9964fd0635 | ||
|
|
e789cd5e9d | ||
|
|
6cb7dedd9b | ||
|
|
39ba17dd1c | ||
|
|
5b66962ac1 | ||
|
|
c05340f631 | ||
|
|
f24c461cbb | ||
|
|
c8ee679799 | ||
|
|
2966c9a60f | ||
|
|
acfcb0c49a | ||
|
|
b92a681795 | ||
|
|
1941e79d21 | ||
|
|
5290fae2e0 | ||
|
|
ecbd370a47 | ||
|
|
d8204e65eb | ||
|
|
9c26e5519b | ||
|
|
060729c4c4 | ||
|
|
65d51ab129 | ||
|
|
afbda030bc | ||
|
|
d111d07fb7 | ||
|
|
30abdddaf3 | ||
|
|
a2f329b8de | ||
|
|
bfa888adc5 | ||
|
|
ac4083bfbb | ||
|
|
5fb686ac0c | ||
|
|
49a7b80680 | ||
|
|
cb11967eac | ||
|
|
a43bfc5a33 | ||
|
|
1d6bc4e09e | ||
|
|
3e14b718ef | ||
|
|
1ae6270561 | ||
|
|
55a601c07e | ||
|
|
7d67b81879 | ||
|
|
cd02144ac3 | ||
|
|
9b247acd1c | ||
|
|
942126ea3d | ||
|
|
2b9ea11701 | ||
|
|
b3d3e14ffe | ||
|
|
62ae5db9fd | ||
|
|
77a49a09a1 | ||
|
|
06c5bbfcfd | ||
|
|
f3063d35be | ||
|
|
e32090bf39 | ||
|
|
7ab500740b | ||
|
|
911bd30d28 | ||
|
|
282857eae0 | ||
|
|
d8c2f99c06 | ||
|
|
16d3f74366 | ||
|
|
5fc28139ea | ||
|
|
b7b6876688 | ||
|
|
235dea329c | ||
|
|
5afdf6fc20 | ||
|
|
385059e973 | ||
|
|
62aed02070 | ||
|
|
6843b8661d | ||
|
|
9da747ea9d | ||
|
|
22964afc69 | ||
|
|
3bc53fd92b | ||
|
|
bd31120569 | ||
|
|
6af124e4d3 | ||
|
|
b3b1d8f193 | ||
|
|
785580115b | ||
|
|
b4bd04c146 | ||
|
|
e88c6b8a59 | ||
|
|
74868238f3 | ||
|
|
61a300250d | ||
|
|
d8dbc0866f | ||
|
|
586d96ae74 | ||
|
|
81032a5745 | ||
|
|
c2d726beaf | ||
|
|
3bafdf7bfd | ||
|
|
edcc7ea34f | ||
|
|
6261a7b5c9 | ||
|
|
881832c92d | ||
|
|
47d4dc7ef0 | ||
|
|
10ce81bf98 | ||
|
|
98b3d9f81e | ||
|
|
81cbb7dcc4 | ||
|
|
9517876bd0 | ||
|
|
231d132792 | ||
|
|
9ada5dfea4 | ||
|
|
476c94407f | ||
|
|
458da0e9b2 | ||
|
|
66673012ac | ||
|
|
46f8b6dc5a | ||
|
|
ee81e69ece | ||
|
|
3927f05267 | ||
|
|
a010ab5a29 | ||
|
|
c49bebd412 | ||
|
|
5a8105f5a0 | ||
|
|
df66adeef6 | ||
|
|
4e2367c868 | ||
|
|
53c701cc0e | ||
|
|
92fced75da | ||
|
|
4dd838b8bc | ||
|
|
0a3c375943 | ||
|
|
64a0760a47 | ||
|
|
2e7db47806 | ||
|
|
d2d56a7f71 | ||
|
|
b4897ff1b5 | ||
|
|
661a573bf5 | ||
|
|
0c9bd87602 | ||
|
|
896d888710 | ||
|
|
76aee7f189 | ||
|
|
147b30f973 | ||
|
|
a73dc72558 | ||
|
|
c99cf5518d | ||
|
|
1391675a3a | ||
|
|
a3b2784f31 | ||
|
|
cbe79ee98c | ||
|
|
eb7a2988bf | ||
|
|
42426b95ee | ||
|
|
262a6f31e7 | ||
|
|
04930c3644 | ||
|
|
44594a8e43 | ||
|
|
690d981f31 | ||
|
|
06ea8d1e6c | ||
|
|
c4e3c5a8be | ||
|
|
03f4e7cc34 | ||
|
|
0e54a08ae0 | ||
|
|
b71c752e9d | ||
|
|
521848f955 | ||
|
|
debb17c952 | ||
|
|
7819740f70 | ||
|
|
b9ffb0d8de | ||
|
|
d59852f594 | ||
|
|
085f482820 | ||
|
|
1cb8da96ce | ||
|
|
50016a9eca | ||
|
|
924f7325b4 | ||
|
|
181f678d9e | ||
|
|
6ce1b83ca2 | ||
|
|
a08a653f20 | ||
|
|
e1f1772080 | ||
|
|
d2a1f792e9 | ||
|
|
e7bd80e05f | ||
|
|
9089c0323c | ||
|
|
197e5663df | ||
|
|
f6dedaa619 | ||
|
|
0906dbe637 | ||
|
|
0629b3f6d6 | ||
|
|
55dfc0ec7d | ||
|
|
b7f8b26f1d | ||
|
|
9ca7dd50bc | ||
|
|
0e73d57bdf | ||
|
|
9de2c17e47 | ||
|
|
1bae6d33f1 | ||
|
|
a52b4d2f43 | ||
|
|
3e038bf124 | ||
|
|
870ed9c435 | ||
|
|
68e7ca3277 | ||
|
|
c9142c2156 | ||
|
|
7d923590ae | ||
|
|
5383ced1ca | ||
|
|
0e6c92dbd9 | ||
|
|
b00978c61a | ||
|
|
fb583e28e0 | ||
|
|
760386e3d7 | ||
|
|
51ba54971a | ||
|
|
2ffcf1e82b | ||
|
|
818f4f45a4 | ||
|
|
2cf19f6af1 | ||
|
|
ef82c6e32b | ||
|
|
0e9b9d6251 | ||
|
|
419b34ea73 | ||
|
|
f64c4ed374 | ||
|
|
1677f404d2 | ||
|
|
a612a363ed | ||
|
|
202bc1144b | ||
|
|
a0c654e43f | ||
|
|
2bb677dc19 | ||
|
|
e33805fe42 | ||
|
|
04ac0a1121 | ||
|
|
d2b0bd4e28 | ||
|
|
d754899563 | ||
|
|
4a81e643a7 | ||
|
|
b23e74f8f5 | ||
|
|
5dc766249a | ||
|
|
a895833c7f | ||
|
|
3f81a3c179 | ||
|
|
f1235f745f | ||
|
|
1ddb1de998 | ||
|
|
c472698b1d | ||
|
|
4e021a0731 | ||
|
|
bfbc156821 | ||
|
|
bfd6274602 | ||
|
|
3abaa5ae84 | ||
|
|
65115a50a9 | ||
|
|
06138e15d0 | ||
|
|
14710e3178 | ||
|
|
f705f09749 | ||
|
|
82c445f41e | ||
|
|
1b40e9d65f | ||
|
|
725f7c8735 | ||
|
|
7cc8578148 | ||
|
|
6adafb8ce7 | ||
|
|
ac47df8475 | ||
|
|
f680cf78ab | ||
|
|
c86e84c534 | ||
|
|
3fb611825c | ||
|
|
1cfdad0176 | ||
|
|
59ba325273 | ||
|
|
c4b7c2623f | ||
|
|
e5b2d2ee45 | ||
|
|
64c226b855 | ||
|
|
e3e1da2fd8 | ||
|
|
57b2f8a413 | ||
|
|
5a76a511db | ||
|
|
283f049780 | ||
|
|
c4743cc867 | ||
|
|
c429a860b1 | ||
|
|
1f748c829b | ||
|
|
dd08c7f701 | ||
|
|
77103193c0 | ||
|
|
16335a6bd6 | ||
|
|
e0f6d8ecf2 | ||
|
|
59c31ff88d | ||
|
|
93bf0c210c | ||
|
|
4f7642b1d2 | ||
|
|
773dca8494 | ||
|
|
3cd26e2e38 | ||
|
|
271761cf04 | ||
|
|
6eea552fb9 | ||
|
|
81dd1a7530 | ||
|
|
2eb6e70e57 | ||
|
|
6bcc67634c | ||
|
|
062d8eb206 | ||
|
|
f0d7496bc6 | ||
|
|
8e2b768236 | ||
|
|
48bf326994 | ||
|
|
159d1102aa | ||
|
|
dbb4dbc0a7 | ||
|
|
777e768243 | ||
|
|
70999a70b8 | ||
|
|
3a6b2c2c35 | ||
|
|
dfb80ba8d6 | ||
|
|
94b818b156 | ||
|
|
f1cea1498b | ||
|
|
345678577a | ||
|
|
fb4138cfe6 | ||
|
|
db5b34d589 | ||
|
|
8963af9229 | ||
|
|
2041789ff4 | ||
|
|
aec86f93ea | ||
|
|
57bfb03e05 | ||
|
|
c2b2476e38 | ||
|
|
fa2027d453 | ||
|
|
9d980e4917 | ||
|
|
673243c6cd | ||
|
|
7376223eb8 | ||
|
|
ecd0b8e22f | ||
|
|
c4a608b5bd | ||
|
|
4d9e21ea16 | ||
|
|
1ee3e035c4 | ||
|
|
b1c0fb3e82 | ||
|
|
de715d2afd | ||
|
|
607cf80dda | ||
|
|
0c8fbd69af | ||
|
|
c2335236be | ||
|
|
123340eada | ||
|
|
852a06f99b | ||
|
|
9f8da5c623 | ||
|
|
077d577c98 | ||
|
|
12f39dbaf5 | ||
|
|
6e9f709279 | ||
|
|
666b6cac33 | ||
|
|
e95c096784 | ||
|
|
745161fbd1 | ||
|
|
8216c33b59 | ||
|
|
a05e7be14e | ||
|
|
e27c40c772 | ||
|
|
e752f3c7a7 | ||
|
|
6f4cab6721 | ||
|
|
2d899ef045 | ||
|
|
4f17c8fb23 | ||
|
|
173a0fce28 | ||
|
|
b04ea8174d | ||
|
|
e40ecc45ad | ||
|
|
277b1614b9 | ||
|
|
88099de688 | ||
|
|
7d81b94c16 | ||
|
|
d627cfc4fa | ||
|
|
bf208bbe4b | ||
|
|
79ba6f813f | ||
|
|
141c0244e4 | ||
|
|
7e0276beb7 | ||
|
|
1bf11b0414 | ||
|
|
c23f3fc5e4 | ||
|
|
016297d2ff | ||
|
|
aa64283b55 | ||
|
|
3973c27238 | ||
|
|
2e32d62237 | ||
|
|
d497b94ad5 | ||
|
|
8c09ae82a4 | ||
|
|
632169f277 | ||
|
|
675371f0d7 | ||
|
|
7e2d09bf12 | ||
|
|
28c681aa96 | ||
|
|
5d39aa92df | ||
|
|
b4dbad5e74 | ||
|
|
b1b099257f | ||
|
|
63e8410841 | ||
|
|
2e1c91cd67 | ||
|
|
391b0a577b | ||
|
|
1d26ac9630 | ||
|
|
03b4f59549 | ||
|
|
9aa3ac3640 | ||
|
|
6339e3c70e | ||
|
|
4cc3220287 | ||
|
|
f32c4f4acd | ||
|
|
aba2ce0923 | ||
|
|
c209ceae2e | ||
|
|
94ac2bd04e | ||
|
|
d1b1d20bcf | ||
|
|
fb723fb8b7 | ||
|
|
fc7c61b11b | ||
|
|
a73db3a1bb | ||
|
|
d2dcbaaec4 | ||
|
|
08147e91d9 | ||
|
|
d034605784 | ||
|
|
64fd852535 | ||
|
|
3fbfc55e84 | ||
|
|
49317582c4 | ||
|
|
5ea01df69b | ||
|
|
4a9f8a9ef5 | ||
|
|
49adff1f3b | ||
|
|
377e165be4 | ||
|
|
07da8031c6 | ||
|
|
be363b9727 | ||
|
|
870a59a2fa | ||
|
|
500cf71f7e | ||
|
|
821e338b75 | ||
|
|
987c91a9ff | ||
|
|
233942c9b6 | ||
|
|
a0ab64a841 | ||
|
|
0cd8f32893 | ||
|
|
904acbc576 | ||
|
|
37dc023fcb | ||
|
|
876ff17e3f | ||
|
|
130df1a767 | ||
|
|
5d7dea3fc3 | ||
|
|
ca8397bc97 | ||
|
|
91023ac8ec | ||
|
|
0ad59e9e29 | ||
|
|
42c551de8a | ||
|
|
62d49a7138 | ||
|
|
bc5cd93e97 | ||
|
|
7bd1ba8075 | ||
|
|
64bb07a026 | ||
|
|
f1902b7fd4 | ||
|
|
8e3f8fc7d0 | ||
|
|
c588dcf0ba | ||
|
|
fa29f51aeb | ||
|
|
ee0b369086 | ||
|
|
2fc45c2468 | ||
|
|
15d2f45f0c | ||
|
|
df7b73212f | ||
|
|
5143b165b5 | ||
|
|
10097323e5 | ||
|
|
c0bd0ffc9f | ||
|
|
2cdec3fc78 | ||
|
|
1a46cdf63c | ||
|
|
83892e096a | ||
|
|
6a0b8b4a3f | ||
|
|
5957fde809 | ||
|
|
5711545b81 | ||
|
|
0758f84dc4 | ||
|
|
4b6c35b5f9 | ||
|
|
d7a9ad1d0a | ||
|
|
bb96c35672 | ||
|
|
0880e5b9e8 | ||
|
|
87af23d98c | ||
|
|
61943d051b | ||
|
|
ef1daf5922 | ||
|
|
bb98cff608 | ||
|
|
620ba9ce03 | ||
|
|
86d94ad310 | ||
|
|
b8cf21ae82 | ||
|
|
7accfdb066 | ||
|
|
99f4394f8e | ||
|
|
748aed96cb | ||
|
|
9161739ee6 | ||
|
|
71cf8be94a | ||
|
|
b48133cd83 | ||
|
|
6b5a57fae9 | ||
|
|
24ccf798c2 | ||
|
|
a298772515 | ||
|
|
2d68594b78 | ||
|
|
b026147c9a | ||
|
|
186a5b77d0 | ||
|
|
518f855a9b | ||
|
|
0d2067787c | ||
|
|
0448a42329 | ||
|
|
a724e15dd6 | ||
|
|
be8fe9d059 | ||
|
|
bd6656107b | ||
|
|
a54e051d41 | ||
|
|
7cde52bf9b | ||
|
|
96037508c1 | ||
|
|
9f2268fb2b | ||
|
|
df167c00eb | ||
|
|
e8f9cda0c6 | ||
|
|
d4a951f547 | ||
|
|
f24e4a7e3c | ||
|
|
98b84c17f1 | ||
|
|
78c411a643 | ||
|
|
6bdf15b41e | ||
|
|
a0fcec2a7a | ||
|
|
63834ab8ab | ||
|
|
b23cfa32bb | ||
|
|
0e22ce54ab | ||
|
|
0f41588701 | ||
|
|
442b542794 | ||
|
|
88fae81b19 | ||
|
|
c4fec00f67 | ||
|
|
9a0cc3e8fa | ||
|
|
3ed2362fe3 | ||
|
|
cd8dd552a4 | ||
|
|
64379f313e | ||
|
|
dc53480126 | ||
|
|
3e06e0e344 | ||
|
|
aa9f6520d4 | ||
|
|
3a56e05c85 | ||
|
|
454a813908 | ||
|
|
31e162ba94 | ||
|
|
5b8d51da38 | ||
|
|
846ea89012 | ||
|
|
dc0bbd5fd6 | ||
|
|
91804d53ea | ||
|
|
3d26206d91 | ||
|
|
92d9dfaef2 | ||
|
|
51025e7f8b | ||
|
|
d52e5e9316 | ||
|
|
3711b3f7f1 | ||
|
|
48c229b52c | ||
|
|
aad435da36 | ||
|
|
9c60259089 | ||
|
|
131105d82c | ||
|
|
f54205ff49 | ||
|
|
1d14fc8041 | ||
|
|
4aec01ad1d | ||
|
|
3630643a0e | ||
|
|
44966c6736 | ||
|
|
7c4b28a35c | ||
|
|
1cdf4addad | ||
|
|
50fa851f23 | ||
|
|
a483b8a900 | ||
|
|
dd6d519135 | ||
|
|
9371db094e | ||
|
|
d9a82f29c7 | ||
|
|
3f57514fa3 | ||
|
|
a5a155bd05 | ||
|
|
c8ea0b0452 | ||
|
|
81fd51c793 | ||
|
|
648d399524 | ||
|
|
345c052353 | ||
|
|
952f1a6c3c | ||
|
|
7ae5b8aae7 | ||
|
|
2e189d771e | ||
|
|
7fa7de1563 | ||
|
|
70d68a25ba | ||
|
|
bfc4371d9e | ||
|
|
6a288676a1 | ||
|
|
874ad2fa34 | ||
|
|
a233167471 | ||
|
|
21dc0d388f | ||
|
|
eff8e7a63f | ||
|
|
03f8b2bc6e | ||
|
|
e215c200c7 | ||
|
|
ae5b02f563 | ||
|
|
aa1a96d201 | ||
|
|
d9f24307ac | ||
|
|
958f8c343a | ||
|
|
70cf4c9f92 | ||
|
|
2d3344ee34 | ||
|
|
b4bc906b6a | ||
|
|
520a15fac6 | ||
|
|
032dff8967 | ||
|
|
3c36b0fb33 | ||
|
|
d51d7a41e4 | ||
|
|
60c926fea5 | ||
|
|
db27aac14b | ||
|
|
d17454772c | ||
|
|
9c9e73ba96 | ||
|
|
e21a78c2b3 | ||
|
|
de0fbf2bb9 | ||
|
|
b330e27fb8 | ||
|
|
a941f66d62 | ||
|
|
d77eba12b2 | ||
|
|
de94fd76de | ||
|
|
1026473684 | ||
|
|
3f9c9893c3 | ||
|
|
574cdd65a3 |
@@ -193,6 +193,139 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ubrandes",
|
||||
"name": "ubrandes ",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/59647284?v=4",
|
||||
"profile": "https://github.com/ubrandes",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "pdewost",
|
||||
"name": "Philippe Dewost",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/17090228?v=4",
|
||||
"profile": "http://blog.dewost.com/",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"example",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "kaduskj",
|
||||
"name": "kaduskj",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/983067?v=4",
|
||||
"profile": "https://github.com/kaduskj",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mkirkland4874",
|
||||
"name": "mkirkland4874",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/36466711?v=4",
|
||||
"profile": "https://github.com/mkirkland4874",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"example"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "jcommisso07",
|
||||
"name": "Joseph Commisso",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/3111054?v=4",
|
||||
"profile": "https://github.com/jcommisso07",
|
||||
"contributions": [
|
||||
"data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dssinger",
|
||||
"name": "David Singer",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/1817903?v=4",
|
||||
"profile": "https://github.com/dssinger",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "oPromessa",
|
||||
"name": "oPromessa",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/21261491?v=4",
|
||||
"profile": "https://github.com/oPromessa",
|
||||
"contributions": [
|
||||
"bug",
|
||||
"ideas",
|
||||
"test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "spencerc99",
|
||||
"name": "Spencer Chang",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/14796580?v=4",
|
||||
"profile": "http://spencerchang.me",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "dgleich",
|
||||
"name": "David Gleich",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/33995?v=4",
|
||||
"profile": "https://www.cs.purdue.edu/homes/dgleich",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "alandefreitas",
|
||||
"name": "Alan de Freitas",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5369819?v=4",
|
||||
"profile": "https://alandefreitas.github.io/alandefreitas/",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "hyfen",
|
||||
"name": "Andrew Louis",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6291?v=4",
|
||||
"profile": "https://hyfen.net",
|
||||
"contributions": [
|
||||
"doc",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "neebah",
|
||||
"name": "neebah",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/71442026?v=4",
|
||||
"profile": "https://github.com/neebah",
|
||||
"contributions": [
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ahti123",
|
||||
"name": "Ahti Liin",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/22232632?v=4",
|
||||
"profile": "https://github.com/ahti123",
|
||||
"contributions": [
|
||||
"code",
|
||||
"bug"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "xwu64",
|
||||
"name": "Xiaoliang Wu",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/10580396?v=4",
|
||||
"profile": "https://github.com/xwu64",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
** Before submitting a bug report, please ensure you are running the most recent version of osxphotos and that the bug is reproducible on the latest version **
|
||||
|
||||
- If you installed with pipx: `pipx upgrade osxphotos`
|
||||
- If you installed with pip: `pip install --upgrade osxphotos`
|
||||
- If you installed the pre-built binary, download and install the latest [release](https://github.com/RhetTbull/osxphotos/releases)
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. What' the full command line you used with osxphotos?
|
||||
2. What was the error output?
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. which version of macOS]
|
||||
- osxphotos version (`osxphotos --version`)
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
9
.github/workflows/tests.yml
vendored
9
.github/workflows/tests.yml
vendored
@@ -4,13 +4,13 @@ on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: macOS-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.7, 3.8]
|
||||
os: [macos-10.15]
|
||||
python-version: ['3.8', '3.9', '3.10']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r dev_requirements.txt
|
||||
pip install -r requirements.txt
|
||||
# - name: Lint with flake8
|
||||
# run: |
|
||||
@@ -31,6 +32,4 @@ jobs:
|
||||
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pip install pytest
|
||||
pip install pytest-mock
|
||||
python -m pytest tests/
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,7 @@ __pycache__
|
||||
t.out
|
||||
.vscode/
|
||||
.tox/
|
||||
.idea/
|
||||
dist/
|
||||
build/
|
||||
working/
|
||||
@@ -14,3 +15,5 @@ osxphotos.egg-info/
|
||||
cli.spec
|
||||
*.pyc
|
||||
docsrc/_build/
|
||||
venv/
|
||||
.python-version
|
||||
|
||||
3
.isort.cfg
Normal file
3
.isort.cfg
Normal file
@@ -0,0 +1,3 @@
|
||||
[settings]
|
||||
profile=black
|
||||
multi_line_output=3
|
||||
1187
CHANGELOG.md
1187
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
15
CONTRIBUTING.md
Normal file
15
CONTRIBUTING.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Contributing
|
||||
|
||||
Contributions of all kinds are welcome! You don't need to know python to contribute to this project. For example, documentation updates are just as welcome as code!
|
||||
|
||||
Please explore open [issues](https://github.com/RhetTbull/osxphotos/issues), [discussions](https://github.com/RhetTbull/osxphotos/discussions), and the project [wiki](https://github.com/RhetTbull/osxphotos/wiki) to learn more about the project.
|
||||
|
||||
If you want to contribute source code, I recommend you explore the [wiki](https://github.com/RhetTbull/osxphotos/wiki/Structure-of-the-code) to learn about the source structure first.
|
||||
|
||||
See the [README.md](tests/README.md) in the tests directory before running any tests.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Be nice to each other. Treat everyone with dignity and respect.
|
||||
|
||||
Abusive behavior of any kind will not be tolerated here.
|
||||
10
MANIFEST.in
10
MANIFEST.in
@@ -1,5 +1,7 @@
|
||||
include README.md
|
||||
include README.rst
|
||||
include osxphotos/templates/*
|
||||
include osxphotos/*.json
|
||||
include osxphotos/*.md
|
||||
include osxphotos/phototemplate.tx
|
||||
include osxphotos/phototemplate.md
|
||||
include osxphotos/queries/*
|
||||
include osxphotos/templates/*
|
||||
include README.md
|
||||
include README.rst
|
||||
14
README.rst
14
README.rst
@@ -16,13 +16,14 @@ You can also easily export both the original and edited photos.
|
||||
Supported operating systems
|
||||
---------------------------
|
||||
|
||||
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Catalina (10.15.7).
|
||||
Beta support for macOS Big Sur (10.16.01/11.01).
|
||||
Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) through macOS Big Sur (11.3).
|
||||
|
||||
If you have access to macOS 12 / Monterey beta and would like to help ensure osxphotos is compatible, please contact me via GitHub.
|
||||
|
||||
This package will read Photos databases for any supported version on any supported macOS version.
|
||||
E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running macOS 10.12 and vice versa.
|
||||
|
||||
Requires python >= ``3.7``.
|
||||
Requires python >= ``3.8``.
|
||||
|
||||
Installation
|
||||
------------
|
||||
@@ -109,6 +110,8 @@ Alternatively, you can also run the command line utility like this: ``python3 -m
|
||||
persons Print out persons (faces) found in the Photos library.
|
||||
places Print out places found in the Photos library.
|
||||
query Query the Photos database using 1 or more search options; if...
|
||||
repl Run interactive osxphotos shell
|
||||
tutorial Display osxphotos tutorial.
|
||||
|
||||
To get help on a specific command, use ``osxphotos help <command_name>``
|
||||
|
||||
@@ -146,6 +149,11 @@ export default library using 'country name/year' as output directory (but use "N
|
||||
|
||||
``osxphotos export ~/Desktop/export --directory "{place.name.country,NoCountry}/{created.year}" --person-keyword --album-keyword --keyword-template "{created.year}" --exiftool --update --verbose``
|
||||
|
||||
find all videos larger than 200MB and add them to Photos album "Big Videos" creating the album if necessary
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``osxphotos query --only-movies --min-size 200MB --add-to-album "Big Videos"``
|
||||
|
||||
Example uses of the package
|
||||
---------------------------
|
||||
|
||||
|
||||
12
build.sh
Executable file
12
build.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
# script to help build osxphotos release
|
||||
# this is unique to my own dev setup
|
||||
|
||||
# source venv/bin/activate
|
||||
rm -rf dist; rm -rf build
|
||||
python3 utils/update_readme.py
|
||||
(cd docsrc && make github && make pdf)
|
||||
# python3 setup.py sdist bdist_wheel
|
||||
python3 -m build
|
||||
./make_cli_exe.sh
|
||||
4
cli.py
4
cli.py
@@ -12,7 +12,7 @@
|
||||
|
||||
"""
|
||||
|
||||
from osxphotos.cli import cli
|
||||
from osxphotos.cli.cli import cli_main
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
cli_main()
|
||||
|
||||
12
dev_requirements.txt
Normal file
12
dev_requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
build
|
||||
m2r2
|
||||
pdbpp
|
||||
pyinstaller==4.4
|
||||
pytest-mock
|
||||
pytest==7.0.1
|
||||
Sphinx
|
||||
sphinx_click
|
||||
sphinx_rtd_theme
|
||||
sphinxcontrib-programoutput
|
||||
twine
|
||||
wheel
|
||||
@@ -1,4 +1,4 @@
|
||||
# Sphinx build info version 1
|
||||
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
|
||||
config: 945c248ccd49635b8e71dede61ad6da7
|
||||
config: bc3dce8a14bcd1b0c8a34e4d16f0011f
|
||||
tags: 645f666f9bcd5a90fca523b33c5a78b7
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Overview: module code — osxphotos 0.41.2 documentation</title>
|
||||
<link rel="stylesheet" href="../_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="../_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="../" src="../_static/documentation_options.js"></script>
|
||||
<title>Overview: module code — osxphotos 0.47.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="../_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="../_static/alabaster.css" />
|
||||
<script data-url_root="../" id="documentation_options" src="../_static/documentation_options.js"></script>
|
||||
<script src="../_static/jquery.js"></script>
|
||||
<script src="../_static/underscore.js"></script>
|
||||
<script src="../_static/doctools.js"></script>
|
||||
@@ -31,11 +31,7 @@
|
||||
<div class="body" role="main">
|
||||
|
||||
<h1>All modules for which code is available</h1>
|
||||
<ul><li><a href="osxphotos/photoinfo/_photoinfo_exifinfo.html">osxphotos.photoinfo._photoinfo_exifinfo</a></li>
|
||||
<li><a href="osxphotos/photoinfo/_photoinfo_export.html">osxphotos.photoinfo._photoinfo_export</a></li>
|
||||
<li><a href="osxphotos/photoinfo/_photoinfo_scoreinfo.html">osxphotos.photoinfo._photoinfo_scoreinfo</a></li>
|
||||
<li><a href="osxphotos/photoinfo/_photoinfo_searchinfo.html">osxphotos.photoinfo._photoinfo_searchinfo</a></li>
|
||||
<li><a href="osxphotos/photoinfo/photoinfo.html">osxphotos.photoinfo.photoinfo</a></li>
|
||||
<ul><li><a href="osxphotos/photoinfo.html">osxphotos.photoinfo</a></li>
|
||||
<li><a href="osxphotos/photosdb/photosdb.html">osxphotos.photosdb.photosdb</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -71,7 +67,7 @@
|
||||
<h3 id="searchlabel">Quick search</h3>
|
||||
<div class="searchformwrapper">
|
||||
<form class="search" action="../search.html" method="get">
|
||||
<input type="text" name="q" aria-labelledby="searchlabel" />
|
||||
<input type="text" name="q" aria-labelledby="searchlabel" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"/>
|
||||
<input type="submit" value="Go" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -93,7 +89,7 @@
|
||||
©2021, Rhet Turnbull.
|
||||
|
||||
|
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||
|
||||
</div>
|
||||
|
||||
1868
docs/_modules/osxphotos/photoinfo.html
Normal file
1868
docs/_modules/osxphotos/photoinfo.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>osxphotos.photoinfo._photoinfo_exifinfo — osxphotos 0.41.0 documentation</title>
|
||||
<title>osxphotos.photoinfo._photoinfo_exifinfo — osxphotos 0.41.4 documentation</title>
|
||||
<link rel="stylesheet" href="../../../_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="../../../_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="../../../" src="../../../_static/documentation_options.js"></script>
|
||||
@@ -183,7 +183,7 @@
|
||||
©2021, Rhet Turnbull.
|
||||
|
||||
|
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.4.3</a>
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
|
||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>osxphotos.photoinfo._photoinfo_scoreinfo — osxphotos 0.41.0 documentation</title>
|
||||
<title>osxphotos.photoinfo._photoinfo_scoreinfo — osxphotos 0.41.4 documentation</title>
|
||||
<link rel="stylesheet" href="../../../_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="../../../_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="../../../" src="../../../_static/documentation_options.js"></script>
|
||||
@@ -208,7 +208,7 @@
|
||||
©2021, Rhet Turnbull.
|
||||
|
||||
|
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.4.3</a>
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
|
||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>osxphotos.photoinfo._photoinfo_searchinfo — osxphotos 0.41.0 documentation</title>
|
||||
<title>osxphotos.photoinfo._photoinfo_searchinfo — osxphotos 0.41.4 documentation</title>
|
||||
<link rel="stylesheet" href="../../../_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="../../../_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="../../../" src="../../../_static/documentation_options.js"></script>
|
||||
@@ -366,7 +366,7 @@
|
||||
©2021, Rhet Turnbull.
|
||||
|
||||
|
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.4.3</a>
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
|
||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,4 +3,6 @@ osxphotos command line interface (CLI)
|
||||
|
||||
.. click:: osxphotos.cli:cli
|
||||
:prog: osxphotos
|
||||
:nested: full
|
||||
:nested: full
|
||||
|
||||
.. program-output:: python3 -m osxphotos --help
|
||||
354
docs/_sources/tutorial.md.txt
Normal file
354
docs/_sources/tutorial.md.txt
Normal file
@@ -0,0 +1,354 @@
|
||||
<!-- OSXPHOTOS-TUTORIAL-HEADER:START -->
|
||||
# OSXPhotos Tutorial
|
||||
|
||||
## Tutorial
|
||||
<!-- OSXPHOTOS-TUTORIAL-HEADER:END -->
|
||||
|
||||
The design philosophy for osxphotos is "make the easy things easy and make the hard things possible". To "make the hard things possible", osxphotos is very flexible and has many, many configuration options -- the `export` command for example, has over 100 command line options. Thus, osxphotos may seem daunting at first. The purpose of this tutorial is to explain a number of common use cases with examples and, hopefully, make osxphotos less daunting to use. osxphotos includes several commands for retrieving information from your Photos library but the one most users are interested in is the `export` command which exports photos from the library so that's the focus of this tutorial.
|
||||
|
||||
### Export your photos
|
||||
|
||||
`osxphotos export /path/to/export`
|
||||
|
||||
This command exports all your photos to the `/path/to/export` directory.
|
||||
|
||||
**Note**: osxphotos uses the term 'photo' to refer to a generic media asset in your Photos Library. A photo may be an image, a video file, a combination of still image and video file (e.g. an Apple "Live Photo" which is an image and an associated "live preview" video file), a JPEG image with an associated RAW image, etc.
|
||||
|
||||
### Export by date
|
||||
|
||||
While the previous command will export all your photos (and videos--see note above), it probably doesn't do exactly what you want. In the previous example, all the photos will be exported to a single folder: `/path/to/export`. If you have a large library with thousands of images and videos, this likely isn't very useful. You can use the `--export-by-date` option to export photos to a folder structure organized by year, month, day, e.g. `2021/04/21`:
|
||||
|
||||
`osxphotos export /path/to/export --export-by-date`
|
||||
|
||||
With this command, a photo that was created on 31 May 2015 would be exported to: `/path/to/export/2015/05/31`
|
||||
|
||||
### Specify directory structure
|
||||
|
||||
If you prefer a different directory structure for your exported images, osxphotos provides a very flexible <!-- OSXPHOTOS-TEMPLATE-SYSTEM-LINK:START -->template system<!-- OSXPHOTOS-TEMPLATE-SYSTEM-LINK:END --> that allows you to specify the directory structure using the `--directory` option. For example, this command exported to a directory structure that looks like: `2015/May` (4-digit year / month name):
|
||||
|
||||
`osxphotos export /path/to/export --directory "{created.year}/{created.month}"`
|
||||
|
||||
The string following `--directory` is an `osxphotos template string`. Template strings are widely used throughout osxphotos and it's worth your time to learn more about them. In a template string, the values between the curly braces, e.g. `{created.year}` are replaced with metadata from the photo being exported. In this case, `{created.year}` is the 4-digit year of the photo's creation date and `{created.month}` is the full month name in the user's locale (e.g. `May`, `mai`, etc.). In the osxphotos template system these are referred to as template fields. The text not included between `{}` pairs is interpreted literally, in this case `/`, is a directory separator.
|
||||
|
||||
osxphotos provides access to almost all the metadata known to Photos about your images. For example, Photos performs reverse geolocation lookup on photos that contain GPS coordinates to assign place names to the photo. Using the `--directory` template, you could thus export photos organized by country name:
|
||||
|
||||
`osxphotos export /path/to/export --directory "{created.year}/{place.name.country}"`
|
||||
|
||||
Of course, some photos might not have an associated place name so the template system allows you specify a default value to use if a template field is null (has no value).
|
||||
|
||||
`osxphotos export /path/to/export --directory "{created.year}/{place.name.country,No-Country}"`
|
||||
|
||||
The value after the ',' in the template string is the default value, in this case 'No-Country'. **Note**: If you don't specify a default value and a template field is null, osxphotos will use "_" (underscore character) as the default.
|
||||
|
||||
Some template fields, such as `{keyword}`, may expand to more than one value. For example, if a photo has keywords of "Travel" and "Vacation", `{keyword}` would expand to "Travel", "Vacation". When used with `--directory`, this would result in the photo being exported to more than one directory (thus more than one copy of the photo would be exported). For example, if `IMG_1234.JPG` has keywords `Travel`, and `Vacation` and you run the following command:
|
||||
|
||||
`osxphotos export /path/to/export --directory "{keyword}"`
|
||||
|
||||
the exported files would be:
|
||||
|
||||
/path/to/export/Travel/IMG_1234.JPG
|
||||
/path/to/export/Vacation/IMG_1234.JPG
|
||||
|
||||
### Specify exported filename
|
||||
|
||||
By default, osxphotos will use the original filename of the photo when exporting. That is, the filename the photo had when it was taken or imported into Photos. This is often something like `IMG_1234.JPG` or `DSC05678.dng`. osxphotos allows you to specify a custom filename template using the `--filename` option in the same way as `--directory` allows you to specify a custom directory name. For example, Photos allows you specify a title or caption for a photo and you can use this in place of the original filename:
|
||||
|
||||
`osxphotos export /path/to/export --filename "{title}"`
|
||||
|
||||
The above command will export photos using the title. Note that you don't need to specify the extension as part of the `--filename` template as osxphotos will automatically add the correct file extension. Some photos might not have a title so in this case, you could use the default value feature to specify a different name for these photos. For example, to use the title as the filename, but if no title is specified, use the original filename instead:
|
||||
|
||||
```txt
|
||||
osxphotos export /path/to/export --filename "{title,{original_name}}"
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
Use photo's title as the filename <──────┘ ││ │
|
||||
││ │
|
||||
Value after comma will be used <───────┘│ │
|
||||
if title is blank │ │
|
||||
│ │
|
||||
The default value can be <────┘ │
|
||||
another template field │
|
||||
│
|
||||
Use photo's original name if no title <──────┘
|
||||
```
|
||||
|
||||
The osxphotos template system also allows for limited conditional logic of the type "If a condition is true then do one thing, otherwise, do a different thing". For example, you can use the `--filename` option to name files that are marked as "Favorites" in Photos differently than other files. For example, to add a "#" to the name of every photo that's a favorite:
|
||||
|
||||
```txt
|
||||
osxphotos export /path/to/export --filename "{original_name}{favorite?#,}"
|
||||
│ │ │││
|
||||
│ │ │││
|
||||
Use photo's original name as filename <──┘ │ │││
|
||||
│ │││
|
||||
'favorite' is True if photo is a Favorite, <───────┘ │││
|
||||
otherwise, False │││
|
||||
│││
|
||||
'?' specifies a conditional <─────────────┘││
|
||||
││
|
||||
Value immediately following ? will be used if <──────┘│
|
||||
preceding template field is True or non-blank │
|
||||
│
|
||||
Value immediately following comma will be used if <──────┘
|
||||
template field is False or blank (null); in this case
|
||||
no value is specified so a blank string "" will be used
|
||||
```
|
||||
|
||||
Like with `--directory`, using a multi-valued template field such as `{keyword}` may result in more than one copy of a photo being exported. For example, if `IMG_1234.JPG` has keywords `Travel`, and `Vacation` and you run the following command:
|
||||
|
||||
`osxphotos export /path/to/export --filename "{keyword}-{original_name}"`
|
||||
|
||||
the exported files would be:
|
||||
|
||||
/path/to/export/Travel-IMG_1234.JPG
|
||||
/path/to/export/Vacation-IMG_1234.JPG
|
||||
|
||||
### Edited photos
|
||||
|
||||
If a photo has been edited in Photos (e.g. cropped, adjusted, etc.) there will be both an original image and an edited image in the Photos Library. By default, osxphotos will export both the original and the edited image. To distinguish between them, osxphotos will append "_edited" to the edited image. For example, if the original image was named `IMG_1234.JPG`, osxphotos will export the original as `IMG_1234.JPG` and the edited version as `IMG_1234_edited.jpeg`. **Note:** Photos changes the extension of edited images to ".jpeg" even if the original was named ".JPG". You can change the suffix appended to edited images using the `--edited-suffix` option:
|
||||
|
||||
`osxphotos export /path/to/export --edited-suffix "_EDIT"`
|
||||
|
||||
In this example, the edited image would be named `IMG_1234_EDIT.jpeg`. Like many options in osxphotos, the `--edited-suffix` option can evaluate an osxphotos template string so you could append the modification date (the date the photo was edited) to all edited photos using this command:
|
||||
|
||||
`osxphotos export /path/to/export --edited-suffix "_{modified.year}-{modified.mm}-{modified.dd}"`
|
||||
|
||||
In this example, if the photo was edited on 21 April 2021, the name of the exported file would be: `IMG_1234_2021-04-21.jpeg`.
|
||||
|
||||
You can tell osxphotos to not export edited photos (that is, only export the original unedited photos) using `--skip-edited`:
|
||||
|
||||
`osxphotos export /path/to/export --skip-edited`
|
||||
|
||||
You can also tell osxphotos to export either the original photo (if the photo has not been edited) or the edited photo (if it has been edited), but not both, using the `--skip-original-if-edited` option:
|
||||
|
||||
`osxphotos export /path/to/export --skip-original-if-edited`
|
||||
|
||||
As mentioned above, Photos renames JPEG images that have been edited with the ".jpeg" extension. Some applications use ".JPG" and others use ".jpg" or ".JPEG". You can use the `--jpeg-ext` option to have osxphotos rename all JPEG files with the same extension. Valid values are jpeg, jpg, JPEG, JPG; e.g. `--jpeg-ext jpg` to use '.jpg' for all JPEGs.
|
||||
|
||||
`osxphotos export /path/to/export --jpeg-ext jpg`
|
||||
|
||||
### Specifying the Photos library
|
||||
|
||||
All the above commands operate on the default Photos library. Most users only use a single Photos library which is also known as the System Photo Library. It is possible to use Photos with more than one library. For example, if you hold down the "Option" key while opening Photos, you can select an alternate Photos library. If you don't specify which library to use, osxphotos will try find the last opened library. Occasionally it can't determine this and in that case, it will use the System Photos Library. If you use more than one Photos library and want to explicitly specify which library to use, you can do so with the `--db` option. (db is short for database and is so named because osxphotos operates on the database that Photos uses to manage your Photos library).
|
||||
|
||||
`osxphotos export /path/to/export --db ~/Pictures/MyAlternateLibrary.photoslibrary`
|
||||
|
||||
### Missing photos
|
||||
|
||||
osxphotos works by copying photos out of the Photos library folder to export them. You may see osxphotos report that one or more photos are missing and thus could not be exported. One possible reason for this is that you are using iCloud to synch your Photos library and Photos either hasn't yet synched the cloud library to the local Mac or you have Photos configured to "Optimize Mac Storage" in Photos Preferences. Another reason is that even if you have Photos configured to download originals to the Mac, Photos does not always download photos from shared albums or original screenshots to the Mac.
|
||||
|
||||
If you encounter missing photos you can tell osxphotos to download the missing photos from iCloud using the `--download-missing` option. `--download-missing` uses AppleScript to communicate with Photos and tell it to download the missing photos. Photos' AppleScript interface is somewhat buggy and you may find that Photos crashes. In this case, osxphotos will attempt to restart Photos to resume the download process. There's also an experimental `--use-photokit` option that will communicate with Photos using a different "PhotoKit" interface. This option must be used together with `--download-missing`:
|
||||
|
||||
`osxphotos export /path/to/export --download-missing`
|
||||
|
||||
`osxphotos export /path/to/export --download-missing --use-photokit`
|
||||
|
||||
### Exporting to external disks
|
||||
|
||||
If you are exporting to an external network attached storage (NAS) device, you may encounter errors if the network connection is unreliable. In this case, you can use the `--retry` option so that osxphotos will automatically retry the export. Use `--retry` with a number that specifies the number of times to retry the export:
|
||||
|
||||
`osxphotos export /path/to/export --retry 3`
|
||||
|
||||
In this example, osxphotos will attempt to export a photo up to 3 times if it encounters an error.
|
||||
|
||||
### Exporting metadata with exported photos
|
||||
|
||||
Photos tracks a tremendous amount of metadata associated with photos in the library such as keywords, faces and persons, reverse geolocation data, and image classification labels. Photos' native export capability does not preserve most of this metadata. osxphotos can, however, access and preserve almost all the metadata associated with photos. Using the free [`exiftool`](https://exiftool.org/) app, osxphotos can write metadata to exported photos. Follow the instructions on the exiftool website to install exiftool then you can use the `--exiftool` option to write metadata to exported photos:
|
||||
|
||||
`osxphotos export /path/to/export --exiftool`
|
||||
|
||||
This will write basic metadata such as keywords, persons, and GPS location to the exported files. osxphotos includes several additional options that can be used in conjunction with `--exiftool` to modify the metadata that is written by `exiftool`. For example, you can use the `--keyword-template` option to specify custom keywords (again, via the osxphotos template system). For example, to use the folder and album a photo is in to create hierarchal keywords in the format used by Lightroom Classic:
|
||||
|
||||
```txt
|
||||
osxphotos export /path/to/export --exiftool --keyword-template "{folder_album(>)}"
|
||||
│ │
|
||||
│ │
|
||||
folder_album results in the folder(s) <──┘ │
|
||||
and album a photo is contained in │
|
||||
│
|
||||
The value in () is used as the path separator <───────┘
|
||||
for joining the folders and albums. For example,
|
||||
if photo is in Folder1/Folder2/Album, (>) produces
|
||||
"Folder1>Folder2>Album" which some programs, such as
|
||||
Lightroom Classic, treat as hierarchal keywords
|
||||
```
|
||||
|
||||
The above command will write all the regular metadata that `--exiftool` normally writes to the file upon export but will also add an additional keyword in the exported metadata in the form "Folder1>Folder2>Album". If you did not include the `(>)` in the template string (e.g. `{folder_album}`), folder_album would render in form "Folder1/Folder2/Album".
|
||||
|
||||
A powerful feature of Photos is that it uses machine learning algorithms to automatically classify or label photos. These labels are used when you search for images in Photos but are not otherwise available to the user. osxphotos is able to read all the labels associated with a photo and makes those available through the template system via the `{label}`. Think of these as automatic keywords as opposed to the keywords you assign manually in Photos. One common use case is to use the automatic labels to create new keywords when exporting images so that these labels are embedded in the image's metadata:
|
||||
|
||||
`osxphotos export /path/to/export --exiftool --keyword-template "{label}"`
|
||||
|
||||
**Note**: When evaluating templates for `--directory` and `--filename`, osxphotos inserts the automatic default value "_" for any template field which is null (empty or blank). This is to ensure that there's never a null directory or filename created. For metadata templates such as `--keyword-template`, osxphotos does not provide an automatic default value thus if the template field is null, no keyword would be created. Of course, you can provide a default value if desired and osxphotos will use this. For example, to add "nolabel" as a keyword for any photo that doesn't have labels:
|
||||
|
||||
`osxphotos export /path/to/export --exiftool --keyword-template "{label,nolabel}"`
|
||||
|
||||
### Sidecar files
|
||||
|
||||
Another way to export metadata about your photos is through the use of sidecar files. These are files that have the same name as your photo (but with a different extension) and carry the metadata. Many digital asset management applications (for example, PhotoPrism, Lightroom, Digikam, etc.) can read or write sidecar files. osxphotos can export metadata in exiftool compatible JSON and XMP formats using the `--sidecar` option. For example, to output metadata to XMP sidecars:
|
||||
|
||||
`osxphotos export /path/to/export --sidecar XMP`
|
||||
|
||||
Unlike `--exiftool`, you do not need to install exiftool to use the `--sidecar` feature. Many of the same configuration options that apply to `--exiftool` to modify metadata, for example, `--keyword-template` can also be used with `--sidecar`.
|
||||
|
||||
Sidecar files are named "photoname.ext.sidecar_ext". For example, if the photo is named `IMG_1234.JPG` and the sidecar format is XMP, the sidecar would be named `IMG_1234.JPG.XMP`. Some applications expect the sidecar in this case to be named `IMG_1234.XMP`. You can use the `-sidecar-drop-ext` option to force osxphotos to name the sidecar files in this manner:
|
||||
|
||||
`osxphotos export /path/to/export --sidecar XMP -sidecar-drop-ext`
|
||||
|
||||
### Updating a previous export
|
||||
|
||||
If you want to use osxphotos to perform periodic backups of your Photos library rather than a one-time export, use the `--update` option. When `osxphotos export` is run, it creates a database file named `.osxphotos_export.db` in the export folder. (**Note** Because the filename starts with a ".", you won't see it in Finder which treats "dot-files" like this as hidden. You will see the file in the Terminal.) . If you run osxphotos with the `--update` option, it will look for this database file and, if found, use it to retrieve state information from the last time it was run to only export new or changed files. For example:
|
||||
|
||||
`osxphotos export /path/to/export --update`
|
||||
|
||||
will read the export database located in `/path/to/export/.osxphotos_export.db` and only export photos that have been added or changed since the last time osxphotos was run. You can run osxphotos with the `--update` option even if it's never been run before. If the database isn't found, osxphotos will create it. If you run `osxphotos export` without `--update` in a folder where you had previously exported photos, it will re-export all the photos. If your intent is to keep a periodic backup of your Photos Library up to date with osxphotos, you should always use `--update`.
|
||||
|
||||
If your workflow involves moving files out of the export directory (for example, you move them into a digital asset management app) but you want to use the features of `--update`, you can use the `--only-new` with `--update` to force osxphotos to only export photos that are new (added to the library) since the last update. In this case, osxphotos will ignore the previously exported files that are now missing. Without `--only-new`, osxphotos would see that previously exported files are missing and re-export them.
|
||||
|
||||
`osxphotos export /path/to/export --update --only-new`
|
||||
|
||||
If your workflow involves editing the images you exported from Photos but you still want to maintain a backup with `--update`, you should use the `--ignore-signature` option. `--ignore-signature` instructs osxphotos to ignore the file's signature (for example, size and date modified) when deciding which files should be updated with `--update`. If you edit a file in the export directory and then run `--update` without `--ignore-signature`, osxphotos will see that the file is different than the one in the Photos library and re-export it.
|
||||
|
||||
`osxphotos export /path/to/export --update --ignore-signature`
|
||||
|
||||
### Dry Run
|
||||
|
||||
You can use the `--dry-run` option to have osxphotos "dry run" or test an export without actually exporting any files. When combined with the `--verbose` option, which causes osxphotos to print out details of every file being exported, this can be a useful tool for testing your export options before actually running a full export. For example, if you are learning the template system and want to verify that your `--directory` and `--filename` templates are correct, `--dry-run --verbose` will print out the name of each file being exported.
|
||||
|
||||
`osxphotos export /path/to/export --dry-run --verbose`
|
||||
|
||||
### Creating a report of all exported files
|
||||
|
||||
You can use the `--report` option to create a report, in comma-separated values (CSV) format that will list the details of all files that were exported, skipped, missing, etc. This file format is compatible with programs such as Microsoft Excel. Provide the name of the report after the `--report` option:
|
||||
|
||||
`osxphotos export /path/to/export --report export.csv`
|
||||
|
||||
### Exporting only certain photos
|
||||
|
||||
By default, osxphotos will export your entire Photos library. If you want to export only certain photos, osxphotos provides a rich set of "query options" that allow you to query the Photos database to filter out only certain photos that match your query criteria. The tutorial does not cover all the query options as there are over 50 of them--read the help text (`osxphotos help export`) to better understand the available query options. No matter which subset of photos you would like to export, there is almost certainly a way for osxphotos to filter these. For example, you can filter for only images that contain certain keywords or images without a title, images from a specific time of day or specific date range, images contained in specific albums, etc.
|
||||
|
||||
For example, to export only photos with keyword `Travel`:
|
||||
|
||||
`osxphotos export /path/to/export --keyword "Travel"`
|
||||
|
||||
Like many options in osxphotos, `--keyword` (and most other query options) can be repeated to search for more than one term. For example, to find photos with keyword `Travel` *or* keyword `Vacation`:
|
||||
|
||||
`osxphotos export /path/to/export --keyword "Travel" --keyword "Vacation"`
|
||||
|
||||
To export only photos contained in the album "Summer Vacation":
|
||||
|
||||
`osxphotos export /path/to/export --album "Summer Vacation"`
|
||||
|
||||
There are also a number of query options to export only certain types of photos. For example, to export only photos taken with iPhone "Portrait Mode":
|
||||
|
||||
`osxphotos export /path/to/export --portrait`
|
||||
|
||||
You can also export photos in a certain date range:
|
||||
|
||||
`osxphotos export /path/to/export --from-date "2020-01-01" --to-date "2020-02-28"`
|
||||
|
||||
### Converting images to JPEG on export
|
||||
|
||||
Photos can store images in many different formats. osxphotos can convert non-JPEG images (for example, RAW photos) to JPEG on export using the `--convert-to-jpeg` option. You can specify the JPEG quality (0: worst, 1.0: best) using `--jpeg-quality`. For example:
|
||||
|
||||
`osxphotos export /path/to/export --convert-to-jpeg --jpeg-quality 0.9`
|
||||
|
||||
### Finder attributes
|
||||
|
||||
In addition to using `exiftool` to write metadata directly to the image metadata, osxphotos can write certain metadata that is available to the Finder and Spotlight but does not modify the actual image file. This is done through something called extended attributes which are stored in the filesystem with a file but do not actually modify the file itself. Finder tags and Finder comments are common examples of these.
|
||||
|
||||
osxphotos can, for example, write any keywords in the image to Finder tags so that you can search for images in Spotlight or the Finder using the `tag:tagname` syntax:
|
||||
|
||||
`osxphotos export /path/to/export --finder-tag-keywords`
|
||||
|
||||
`--finder-tag-keywords` also works with `--keyword-template` as described above in the section on `exiftool`:
|
||||
|
||||
`osxphotos export /path/to/export --finder-tag-keywords --keyword-template "{label}"`
|
||||
|
||||
The `--xattr-template` option allows you to set a variety of other extended attributes. It is used in the format `--xattr-template ATTRIBUTE TEMPLATE` where ATTRIBUTE is one of 'authors','comment', 'copyright', 'description', 'findercomment', 'headline', 'keywords'.
|
||||
|
||||
For example, to set Finder comment to the photo's title and description:
|
||||
|
||||
`osxphotos export /path/to/export --xattr-template findercomment "{title}{newline}{descr}"`
|
||||
|
||||
In the template string above, `{newline}` instructs osxphotos to insert a new line character ("\n") between the title and description. In this example, if `{title}` or `{descr}` is empty, you'll get "title\n" or "\ndescription" which may not be desired so you can use more advanced features of the template system to handle these cases:
|
||||
|
||||
`osxphotos export /path/to/export --xattr-template findercomment "{title}{title?{descr?{newline},},}{descr}"`
|
||||
|
||||
Explanation of the template string:
|
||||
|
||||
```txt
|
||||
{title}{title?{descr?{newline},},}{descr}
|
||||
│ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
└──> insert title │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
└───> is there a title?
|
||||
│ │ │ │ │
|
||||
└───> if so, is there a description?
|
||||
│ │ │ │
|
||||
└───> if so, insert new line
|
||||
│ │ │
|
||||
└───> if descr is blank, insert nothing
|
||||
│ │
|
||||
└───> if title is blank, insert nothing
|
||||
│
|
||||
└───> finally, insert description
|
||||
```
|
||||
|
||||
In this example, `title?` demonstrates use of the boolean (True/False) feature of the template system. `title?` is read as "Is the title True (or not blank/empty)? If so, then the value immediately following the `?` is used in place of `title`. If `title` is blank, then the value immediately following the comma is used instead. The format for boolean fields is `field?value if true,value if false`. Either `value if true` or `value if false` may be blank, in which case a blank string ("") is used for the value and both may also be an entirely new template string as seen in the above example. Using this format, template strings may be nested inside each other to form complex `if-then-else` statements.
|
||||
|
||||
The above example, while complex to read, shows how flexible the osxphotos template system is. If you invest a little time learning how to use the template system you can easily handle almost any use case you have.
|
||||
|
||||
See Extended Attributes section in the help for `osxphotos export` for additional information about this feature.
|
||||
|
||||
### Saving and loading options
|
||||
|
||||
If you repeatedly run a complex osxphotos export command (for example, to regularly back-up your Photos library), you can save all the options to a configuration file for future use (`--save-config FILE`) and then load them (`--load-config FILE`) instead of repeating each option on the command line.
|
||||
|
||||
To save the configuration:
|
||||
|
||||
`osxphotos export /path/to/export <all your options here> --update --save-config osxphotos.toml`
|
||||
|
||||
Then the next to you run osxphotos, you can simply do this:
|
||||
|
||||
`osxphotos export /path/to/export --load-config osxphotos.toml`
|
||||
|
||||
The configuration file is a plain text file in [TOML](https://toml.io/en/) format so the `.toml` extension is standard but you can name the file anything you like.
|
||||
|
||||
### An example from an actual osxphotos user
|
||||
|
||||
Here's a comprehensive use case from an actual osxphotos user that integrates many of the concepts discussed in this tutorial (thank-you Philippe for contributing this!):
|
||||
|
||||
I usually import my iPhone’s photo roll on a more or less regular basis, and it
|
||||
includes photos and videos. As a result, the size ot my Photos library may rise
|
||||
very quickly. Nevertheless, I will tag and geolocate everything as Photos has a
|
||||
quite good keyword management system.
|
||||
|
||||
After a while, I want to take most of the videos out of the library and move them
|
||||
to a separate "videos" folder on a different folder / volume. As I might want to
|
||||
use them in Final Cut Pro, and since Final Cut is able to import Finder tags into
|
||||
its internal library tagging system, I will use osxphotos to do just this.
|
||||
|
||||
Picking the videos can be left to Photos, using a smart folder for instance. Then
|
||||
just add a keyword to all videos to be processed. Here I chose "Quik" as I wanted
|
||||
to spot all videos created on my iPhone using the Quik application (now part of
|
||||
GoPro).
|
||||
|
||||
I want to retrieve my keywords only and make sure they populate the Finder tags, as
|
||||
well as export all the persons identified in the videos by Photos. I also want to
|
||||
merge any keywords or persons already in the video metadata with the exported
|
||||
metadata.
|
||||
|
||||
Keeping Photo’s edited titles and descriptions and putting both in the Finder
|
||||
comments field in a readable manner is also enabled.
|
||||
|
||||
And I want to keep the file’s creation date (using `--touch-file`).
|
||||
|
||||
Finally, use `--strip` to remove any leading or trailing whitespace from processed
|
||||
template fields.
|
||||
|
||||
`osxphotos export ~/Desktop/folder for exported videos/ --keyword Quik --only-movies --db /path to my.photoslibrary --touch-file --finder-tag-keywords --person-keyword --xattr-template findercomment "{title}{title?{descr?{newline},},}{descr}" --exiftool-merge-keywords --exiftool-merge-persons --exiftool --strip`
|
||||
|
||||
### Conclusion
|
||||
|
||||
osxphotos is very flexible. If you merely want to backup your Photos library, then spending a few minutes to understand the `--directory` option is likely all you need and you can be up and running in minutes. However, if you have a more complex workflow, osxphotos likely provides options to implement your workflow. This tutorial does not attempt to cover every option offered by osxphotos but hopefully it provides a good understanding of what kinds of things are possible and where to explore if you want to learn more.
|
||||
111
docs/_static/basic.css
vendored
111
docs/_static/basic.css
vendored
@@ -130,7 +130,7 @@ ul.search li a {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ul.search li div.context {
|
||||
ul.search li p.context {
|
||||
color: #888;
|
||||
margin: 2px 0 0 30px;
|
||||
text-align: left;
|
||||
@@ -277,25 +277,25 @@ p.rubric {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
img.align-left, .figure.align-left, object.align-left {
|
||||
img.align-left, figure.align-left, .figure.align-left, object.align-left {
|
||||
clear: left;
|
||||
float: left;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
img.align-right, .figure.align-right, object.align-right {
|
||||
img.align-right, figure.align-right, .figure.align-right, object.align-right {
|
||||
clear: right;
|
||||
float: right;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
img.align-center, .figure.align-center, object.align-center {
|
||||
img.align-center, figure.align-center, .figure.align-center, object.align-center {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
img.align-default, .figure.align-default {
|
||||
img.align-default, figure.align-default, .figure.align-default {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
@@ -319,7 +319,8 @@ img.align-default, .figure.align-default {
|
||||
|
||||
/* -- sidebars -------------------------------------------------------------- */
|
||||
|
||||
div.sidebar {
|
||||
div.sidebar,
|
||||
aside.sidebar {
|
||||
margin: 0 0 0.5em 1em;
|
||||
border: 1px solid #ddb;
|
||||
padding: 7px;
|
||||
@@ -377,12 +378,14 @@ div.body p.centered {
|
||||
/* -- content of sidebars/topics/admonitions -------------------------------- */
|
||||
|
||||
div.sidebar > :last-child,
|
||||
aside.sidebar > :last-child,
|
||||
div.topic > :last-child,
|
||||
div.admonition > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
div.sidebar::after,
|
||||
aside.sidebar::after,
|
||||
div.topic::after,
|
||||
div.admonition::after,
|
||||
blockquote::after {
|
||||
@@ -455,20 +458,22 @@ td > :last-child {
|
||||
|
||||
/* -- figures --------------------------------------------------------------- */
|
||||
|
||||
div.figure {
|
||||
div.figure, figure {
|
||||
margin: 0.5em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
div.figure p.caption {
|
||||
div.figure p.caption, figcaption {
|
||||
padding: 0.3em;
|
||||
}
|
||||
|
||||
div.figure p.caption span.caption-number {
|
||||
div.figure p.caption span.caption-number,
|
||||
figcaption span.caption-number {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
div.figure p.caption span.caption-text {
|
||||
div.figure p.caption span.caption-text,
|
||||
figcaption span.caption-text {
|
||||
}
|
||||
|
||||
/* -- field list styles ----------------------------------------------------- */
|
||||
@@ -503,6 +508,63 @@ table.hlist td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
/* -- object description styles --------------------------------------------- */
|
||||
|
||||
.sig {
|
||||
font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
|
||||
}
|
||||
|
||||
.sig-name, code.descname {
|
||||
background-color: transparent;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sig-name {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
code.descname {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.sig-prename, code.descclassname {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.optional {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.sig-paren {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.sig-param.n {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* C++ specific styling */
|
||||
|
||||
.sig-inline.c-texpr,
|
||||
.sig-inline.cpp-texpr {
|
||||
font-family: unset;
|
||||
}
|
||||
|
||||
.sig.c .k, .sig.c .kt,
|
||||
.sig.cpp .k, .sig.cpp .kt {
|
||||
color: #0033B3;
|
||||
}
|
||||
|
||||
.sig.c .m,
|
||||
.sig.cpp .m {
|
||||
color: #1750EB;
|
||||
}
|
||||
|
||||
.sig.c .s, .sig.c .sc,
|
||||
.sig.cpp .s, .sig.cpp .sc {
|
||||
color: #067D17;
|
||||
}
|
||||
|
||||
|
||||
/* -- other body styles ----------------------------------------------------- */
|
||||
|
||||
@@ -629,14 +691,6 @@ dl.glossary dt {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.optional {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.sig-paren {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.versionmodified {
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -677,8 +731,9 @@ dl.glossary dt {
|
||||
|
||||
.classifier:before {
|
||||
font-style: normal;
|
||||
margin: 0.5em;
|
||||
margin: 0 0.5em;
|
||||
content: ":";
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
abbr, acronym {
|
||||
@@ -765,8 +820,12 @@ div.code-block-caption code {
|
||||
|
||||
table.highlighttable td.linenos,
|
||||
span.linenos,
|
||||
div.doctest > div.highlight span.gp { /* gp: Generic.Prompt */
|
||||
user-select: none;
|
||||
div.highlight span.gp { /* gp: Generic.Prompt */
|
||||
user-select: none;
|
||||
-webkit-user-select: text; /* Safari fallback only */
|
||||
-webkit-user-select: none; /* Chrome/Safari */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* IE10+ */
|
||||
}
|
||||
|
||||
div.code-block-caption span.caption-number {
|
||||
@@ -781,16 +840,6 @@ div.literal-block-wrapper {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
code.descname {
|
||||
background-color: transparent;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
code.descclassname {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
code.xref, a code {
|
||||
background-color: transparent;
|
||||
font-weight: bold;
|
||||
|
||||
2
docs/_static/doctools.js
vendored
2
docs/_static/doctools.js
vendored
@@ -301,12 +301,14 @@ var Documentation = {
|
||||
window.location.href = prevHref;
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 39: // right
|
||||
var nextHref = $('link[rel="next"]').prop('href');
|
||||
if (nextHref) {
|
||||
window.location.href = nextHref;
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
2
docs/_static/documentation_options.js
vendored
2
docs/_static/documentation_options.js
vendored
@@ -1,6 +1,6 @@
|
||||
var DOCUMENTATION_OPTIONS = {
|
||||
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
|
||||
VERSION: '0.41.2',
|
||||
VERSION: '0.47.1',
|
||||
LANGUAGE: 'None',
|
||||
COLLAPSE_INDEX: false,
|
||||
BUILDER: 'html',
|
||||
|
||||
15
docs/_static/searchtools.js
vendored
15
docs/_static/searchtools.js
vendored
@@ -282,7 +282,10 @@ var Search = {
|
||||
complete: function(jqxhr, textstatus) {
|
||||
var data = jqxhr.responseText;
|
||||
if (data !== '' && data !== undefined) {
|
||||
listItem.append(Search.makeSearchSummary(data, searchterms, hlterms));
|
||||
var summary = Search.makeSearchSummary(data, searchterms, hlterms);
|
||||
if (summary) {
|
||||
listItem.append(summary);
|
||||
}
|
||||
}
|
||||
Search.output.append(listItem);
|
||||
setTimeout(function() {
|
||||
@@ -325,7 +328,9 @@ var Search = {
|
||||
var results = [];
|
||||
|
||||
for (var prefix in objects) {
|
||||
for (var name in objects[prefix]) {
|
||||
for (var iMatch = 0; iMatch != objects[prefix].length; ++iMatch) {
|
||||
var match = objects[prefix][iMatch];
|
||||
var name = match[4];
|
||||
var fullname = (prefix ? prefix + '.' : '') + name;
|
||||
var fullnameLower = fullname.toLowerCase()
|
||||
if (fullnameLower.indexOf(object) > -1) {
|
||||
@@ -339,7 +344,6 @@ var Search = {
|
||||
} else if (parts[parts.length - 1].indexOf(object) > -1) {
|
||||
score += Scorer.objPartialMatch;
|
||||
}
|
||||
var match = objects[prefix][name];
|
||||
var objname = objnames[match[1]][2];
|
||||
var title = titles[match[0]];
|
||||
// If more than one term searched for, we require other words to be
|
||||
@@ -498,6 +502,9 @@ var Search = {
|
||||
*/
|
||||
makeSearchSummary : function(htmlText, keywords, hlwords) {
|
||||
var text = Search.htmlToText(htmlText);
|
||||
if (text == "") {
|
||||
return null;
|
||||
}
|
||||
var textLower = text.toLowerCase();
|
||||
var start = 0;
|
||||
$.each(keywords, function() {
|
||||
@@ -509,7 +516,7 @@ var Search = {
|
||||
var excerpt = ((start > 0) ? '...' : '') +
|
||||
$.trim(text.substr(start, 240)) +
|
||||
((start + 240 - text.length) ? '...' : '');
|
||||
var rv = $('<div class="context"></div>').text(excerpt);
|
||||
var rv = $('<p class="context"></p>').text(excerpt);
|
||||
$.each(hlwords, function() {
|
||||
rv = rv.highlightText(this, 'highlighted');
|
||||
});
|
||||
|
||||
2042
docs/_static/underscore-1.13.1.js
vendored
Normal file
2042
docs/_static/underscore-1.13.1.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
8
docs/_static/underscore.js
vendored
8
docs/_static/underscore.js
vendored
File diff suppressed because one or more lines are too long
1360
docs/cli.html
1360
docs/cli.html
File diff suppressed because it is too large
Load Diff
1835
docs/genindex.html
1835
docs/genindex.html
File diff suppressed because it is too large
Load Diff
118
docs/index.html
118
docs/index.html
@@ -4,11 +4,12 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Welcome to osxphotos’s documentation! — osxphotos 0.41.2 documentation</title>
|
||||
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||
|
||||
<title>Welcome to osxphotos’s documentation! — osxphotos 0.47.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
<script src="_static/jquery.js"></script>
|
||||
<script src="_static/underscore.js"></script>
|
||||
<script src="_static/doctools.js"></script>
|
||||
@@ -31,30 +32,30 @@
|
||||
|
||||
<div class="body" role="main">
|
||||
|
||||
<div class="section" id="welcome-to-osxphotos-s-documentation">
|
||||
<section id="welcome-to-osxphotos-s-documentation">
|
||||
<h1>Welcome to osxphotos’s documentation!<a class="headerlink" href="#welcome-to-osxphotos-s-documentation" title="Permalink to this headline">¶</a></h1>
|
||||
</div>
|
||||
<div class="section" id="osxphotos">
|
||||
</section>
|
||||
<section id="osxphotos">
|
||||
<h1>OSXPhotos<a class="headerlink" href="#osxphotos" title="Permalink to this headline">¶</a></h1>
|
||||
<div class="section" id="what-is-osxphotos">
|
||||
<section id="what-is-osxphotos">
|
||||
<h2>What is osxphotos?<a class="headerlink" href="#what-is-osxphotos" title="Permalink to this headline">¶</a></h2>
|
||||
<p>OSXPhotos provides both the ability to interact with and query Apple’s Photos.app library on macOS directly from your python code
|
||||
as well as a very flexible command line interface (CLI) app for exporting photos.
|
||||
You can query the Photos library database – for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc.
|
||||
You can also easily export both the original and edited photos.</p>
|
||||
</div>
|
||||
<div class="section" id="supported-operating-systems">
|
||||
</section>
|
||||
<section id="supported-operating-systems">
|
||||
<h2>Supported operating systems<a class="headerlink" href="#supported-operating-systems" title="Permalink to this headline">¶</a></h2>
|
||||
<p>Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) until macOS Catalina (10.15.7).
|
||||
Beta support for macOS Big Sur (10.16.01/11.01).</p>
|
||||
<p>Only works on macOS (aka Mac OS X). Tested on macOS Sierra (10.12.6) through macOS Big Sur (11.3).</p>
|
||||
<p>If you have access to macOS 12 / Monterey beta and would like to help ensure osxphotos is compatible, please contact me via GitHub.</p>
|
||||
<p>This package will read Photos databases for any supported version on any supported macOS version.
|
||||
E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine running macOS 10.12 and vice versa.</p>
|
||||
<p>Requires python >= <code class="docutils literal notranslate"><span class="pre">3.7</span></code>.</p>
|
||||
</div>
|
||||
<div class="section" id="installation">
|
||||
<p>Requires python >= <code class="docutils literal notranslate"><span class="pre">3.8</span></code>.</p>
|
||||
</section>
|
||||
<section id="installation">
|
||||
<h2>Installation<a class="headerlink" href="#installation" title="Permalink to this headline">¶</a></h2>
|
||||
<p>If you are new to python and just want to use the command line application, I recommend you to install using pipx. See other advanced options below.</p>
|
||||
<div class="section" id="installation-using-pipx">
|
||||
<section id="installation-using-pipx">
|
||||
<h3>Installation using pipx<a class="headerlink" href="#installation-using-pipx" title="Permalink to this headline">¶</a></h3>
|
||||
<p>If you aren’t familiar with installing python applications, I recommend you install <code class="docutils literal notranslate"><span class="pre">osxphotos</span></code> with <a class="reference external" href="https://github.com/pipxproject/pipx">pipx</a>. If you use <code class="docutils literal notranslate"><span class="pre">pipx</span></code>, you will not need to create a virtual environment as <code class="docutils literal notranslate"><span class="pre">pipx</span></code> takes care of this. The easiest way to do this on a Mac is to use <a class="reference external" href="https://brew.sh/">homebrew</a>:</p>
|
||||
<ul class="simple">
|
||||
@@ -64,15 +65,15 @@ E.g. you can read a database created with Photos 5.0 on MacOS 10.15 on a machine
|
||||
<li><p>Then type this: <code class="docutils literal notranslate"><span class="pre">pipx</span> <span class="pre">install</span> <span class="pre">osxphotos</span></code></p></li>
|
||||
<li><p>Now you should be able to run <code class="docutils literal notranslate"><span class="pre">osxphotos</span></code> by typing: <code class="docutils literal notranslate"><span class="pre">osxphotos</span></code></p></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="installation-using-pip">
|
||||
</section>
|
||||
<section id="installation-using-pip">
|
||||
<h3>Installation using pip<a class="headerlink" href="#installation-using-pip" title="Permalink to this headline">¶</a></h3>
|
||||
<p>You can also install directly from <a class="reference external" href="https://pypi.org/project/osxphotos/">pypi</a>:</p>
|
||||
<div class="highlight-default notranslate"><div class="highlight"><pre><span></span><span class="n">pip</span> <span class="n">install</span> <span class="n">osxphotos</span>
|
||||
</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="installation-from-git-repository">
|
||||
</section>
|
||||
<section id="installation-from-git-repository">
|
||||
<h3>Installation from git repository<a class="headerlink" href="#installation-from-git-repository" title="Permalink to this headline">¶</a></h3>
|
||||
<p>OSXPhotos uses setuptools, thus simply run:</p>
|
||||
<div class="highlight-default notranslate"><div class="highlight"><pre><span></span><span class="n">git</span> <span class="n">clone</span> <span class="n">https</span><span class="p">:</span><span class="o">//</span><span class="n">github</span><span class="o">.</span><span class="n">com</span><span class="o">/</span><span class="n">RhetTbull</span><span class="o">/</span><span class="n">osxphotos</span><span class="o">.</span><span class="n">git</span>
|
||||
@@ -87,9 +88,9 @@ I recommend you install the latest version from <a class="reference external" hr
|
||||
libraries. If you just want to use the command line utility, you can download a pre-built executable of the latest
|
||||
<a class="reference external" href="https://github.com/RhetTbull/osxphotos/releases">release</a> or you can install via <code class="docutils literal notranslate"><span class="pre">pip</span></code> which also installs the command line app.
|
||||
If you aren’t comfortable with running python on your Mac, start with the pre-built executable or <code class="docutils literal notranslate"><span class="pre">pipx</span></code> as described above.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="command-line-usage">
|
||||
</section>
|
||||
</section>
|
||||
<section id="command-line-usage">
|
||||
<h2>Command Line Usage<a class="headerlink" href="#command-line-usage" title="Permalink to this headline">¶</a></h2>
|
||||
<p>This package will install a command line utility called <code class="docutils literal notranslate"><span class="pre">osxphotos</span></code> that allows you to query the Photos database and export photos.
|
||||
Alternatively, you can also run the command line utility like this: <code class="docutils literal notranslate"><span class="pre">python3</span> <span class="pre">-m</span> <span class="pre">osxphotos</span></code></p>
|
||||
@@ -122,37 +123,43 @@ Alternatively, you can also run the command line utility like this: <code class=
|
||||
<span class="n">persons</span> <span class="n">Print</span> <span class="n">out</span> <span class="n">persons</span> <span class="p">(</span><span class="n">faces</span><span class="p">)</span> <span class="n">found</span> <span class="ow">in</span> <span class="n">the</span> <span class="n">Photos</span> <span class="n">library</span><span class="o">.</span>
|
||||
<span class="n">places</span> <span class="n">Print</span> <span class="n">out</span> <span class="n">places</span> <span class="n">found</span> <span class="ow">in</span> <span class="n">the</span> <span class="n">Photos</span> <span class="n">library</span><span class="o">.</span>
|
||||
<span class="n">query</span> <span class="n">Query</span> <span class="n">the</span> <span class="n">Photos</span> <span class="n">database</span> <span class="n">using</span> <span class="mi">1</span> <span class="ow">or</span> <span class="n">more</span> <span class="n">search</span> <span class="n">options</span><span class="p">;</span> <span class="k">if</span><span class="o">...</span>
|
||||
<span class="n">repl</span> <span class="n">Run</span> <span class="n">interactive</span> <span class="n">osxphotos</span> <span class="n">shell</span>
|
||||
<span class="n">tutorial</span> <span class="n">Display</span> <span class="n">osxphotos</span> <span class="n">tutorial</span><span class="o">.</span>
|
||||
</pre></div>
|
||||
</div>
|
||||
<p>To get help on a specific command, use <code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">help</span> <span class="pre"><command_name></span></code></p>
|
||||
<div class="section" id="command-line-examples">
|
||||
<section id="command-line-examples">
|
||||
<h3>Command line examples<a class="headerlink" href="#command-line-examples" title="Permalink to this headline">¶</a></h3>
|
||||
<div class="section" id="export-all-photos-to-desktop-export-group-in-folders-by-date-created">
|
||||
<section id="export-all-photos-to-desktop-export-group-in-folders-by-date-created">
|
||||
<h4>export all photos to ~/Desktop/export group in folders by date created<a class="headerlink" href="#export-all-photos-to-desktop-export-group-in-folders-by-date-created" title="Permalink to this headline">¶</a></h4>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">--export-by-date</span> <span class="pre">~/Pictures/Photos\</span> <span class="pre">Library.photoslibrary</span> <span class="pre">~/Desktop/export</span></code></p>
|
||||
<p><strong>Note</strong>: Photos library/database path can also be specified using <code class="docutils literal notranslate"><span class="pre">--db</span></code> option:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">--export-by-date</span> <span class="pre">--db</span> <span class="pre">~/Pictures/Photos\</span> <span class="pre">Library.photoslibrary</span> <span class="pre">~/Desktop/export</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="find-all-photos-with-keyword-kids-and-output-results-to-json-file-named-results-json">
|
||||
</section>
|
||||
<section id="find-all-photos-with-keyword-kids-and-output-results-to-json-file-named-results-json">
|
||||
<h4>find all photos with keyword “Kids” and output results to json file named results.json:<a class="headerlink" href="#find-all-photos-with-keyword-kids-and-output-results-to-json-file-named-results-json" title="Permalink to this headline">¶</a></h4>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">query</span> <span class="pre">--keyword</span> <span class="pre">Kids</span> <span class="pre">--json</span> <span class="pre">~/Pictures/Photos\</span> <span class="pre">Library.photoslibrary</span> <span class="pre">>results.json</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="export-photos-to-file-structure-based-on-4-digit-year-and-full-name-of-month-of-photo-s-creation-date">
|
||||
</section>
|
||||
<section id="export-photos-to-file-structure-based-on-4-digit-year-and-full-name-of-month-of-photo-s-creation-date">
|
||||
<h4>export photos to file structure based on 4-digit year and full name of month of photo’s creation date:<a class="headerlink" href="#export-photos-to-file-structure-based-on-4-digit-year-and-full-name-of-month-of-photo-s-creation-date" title="Permalink to this headline">¶</a></h4>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">~/Desktop/export</span> <span class="pre">--directory</span> <span class="pre">"{created.year}/{created.month}"</span></code></p>
|
||||
<p>(by default, it will attempt to use the system library)</p>
|
||||
</div>
|
||||
<div class="section" id="export-photos-to-file-structure-based-on-4-digit-year-of-photo-s-creation-date-and-add-keywords-for-media-type-and-labels-labels-are-only-awailable-on-photos-5-and-higher">
|
||||
</section>
|
||||
<section id="export-photos-to-file-structure-based-on-4-digit-year-of-photo-s-creation-date-and-add-keywords-for-media-type-and-labels-labels-are-only-awailable-on-photos-5-and-higher">
|
||||
<h4>export photos to file structure based on 4-digit year of photo’s creation date and add keywords for media type and labels (labels are only awailable on Photos 5 and higher):<a class="headerlink" href="#export-photos-to-file-structure-based-on-4-digit-year-of-photo-s-creation-date-and-add-keywords-for-media-type-and-labels-labels-are-only-awailable-on-photos-5-and-higher" title="Permalink to this headline">¶</a></h4>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">~/Desktop/export</span> <span class="pre">--directory</span> <span class="pre">"{created.year}"</span> <span class="pre">--keyword-template</span> <span class="pre">"{label}"</span> <span class="pre">--keyword-template</span> <span class="pre">"{media_type}"</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="export-default-library-using-country-name-year-as-output-directory-but-use-nocountry-year-if-country-not-specified-add-persons-album-names-and-year-as-keywords-write-exif-metadata-to-files-when-exporting-update-only-changed-files-print-verbose-ouput">
|
||||
</section>
|
||||
<section id="export-default-library-using-country-name-year-as-output-directory-but-use-nocountry-year-if-country-not-specified-add-persons-album-names-and-year-as-keywords-write-exif-metadata-to-files-when-exporting-update-only-changed-files-print-verbose-ouput">
|
||||
<h4>export default library using ‘country name/year’ as output directory (but use “NoCountry/year” if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput<a class="headerlink" href="#export-default-library-using-country-name-year-as-output-directory-but-use-nocountry-year-if-country-not-specified-add-persons-album-names-and-year-as-keywords-write-exif-metadata-to-files-when-exporting-update-only-changed-files-print-verbose-ouput" title="Permalink to this headline">¶</a></h4>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">~/Desktop/export</span> <span class="pre">--directory</span> <span class="pre">"{place.name.country,NoCountry}/{created.year}"</span>  <span class="pre">--person-keyword</span> <span class="pre">--album-keyword</span> <span class="pre">--keyword-template</span> <span class="pre">"{created.year}"</span> <span class="pre">--exiftool</span> <span class="pre">--update</span> <span class="pre">--verbose</span></code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="example-uses-of-the-package">
|
||||
</section>
|
||||
<section id="find-all-videos-larger-than-200mb-and-add-them-to-photos-album-big-videos-creating-the-album-if-necessary">
|
||||
<h4>find all videos larger than 200MB and add them to Photos album “Big Videos” creating the album if necessary<a class="headerlink" href="#find-all-videos-larger-than-200mb-and-add-them-to-photos-album-big-videos-creating-the-album-if-necessary" title="Permalink to this headline">¶</a></h4>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">query</span> <span class="pre">--only-movies</span> <span class="pre">--min-size</span> <span class="pre">200MB</span> <span class="pre">--add-to-album</span> <span class="pre">"Big</span> <span class="pre">Videos"</span></code></p>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
<section id="example-uses-of-the-package">
|
||||
<h2>Example uses of the package<a class="headerlink" href="#example-uses-of-the-package" title="Permalink to this headline">¶</a></h2>
|
||||
<div class="highlight-python notranslate"><div class="highlight"><pre><span></span><span class="sd">""" Simple usage of the package """</span>
|
||||
<span class="kn">import</span> <span class="nn">osxphotos</span>
|
||||
@@ -268,46 +275,29 @@ Alternatively, you can also run the command line utility like this: <code class=
|
||||
<span class="n">export</span><span class="p">()</span> <span class="c1"># pylint: disable=no-value-for-parameter</span>
|
||||
</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="package-interface">
|
||||
</section>
|
||||
<section id="package-interface">
|
||||
<h2>Package Interface<a class="headerlink" href="#package-interface" title="Permalink to this headline">¶</a></h2>
|
||||
<p>Reference full documentation on <a class="reference external" href="https://github.com/RhetTbull/osxphotos/blob/master/README.md">GitHub</a></p>
|
||||
<div class="toctree-wrapper compound">
|
||||
<ul>
|
||||
<li class="toctree-l1"><a class="reference internal" href="cli.html">osxphotos command line interface (CLI)</a><ul>
|
||||
<li class="toctree-l2"><a class="reference internal" href="cli.html#osxphotos">osxphotos</a><ul>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-about">about</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-albums">albums</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-dump">dump</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-export">export</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-help">help</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-info">info</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-keywords">keywords</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-labels">labels</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-list">list</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-persons">persons</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-places">places</a></li>
|
||||
<li class="toctree-l3"><a class="reference internal" href="cli.html#osxphotos-query">query</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="toctree-l1"><a class="reference internal" href="cli.html">osxphotos command line interface (CLI)</a></li>
|
||||
<li class="toctree-l1"><a class="reference internal" href="reference.html">osxphotos package</a><ul>
|
||||
<li class="toctree-l2"><a class="reference internal" href="reference.html#osxphotos-module">osxphotos module</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="indices-and-tables">
|
||||
</section>
|
||||
</section>
|
||||
<section id="indices-and-tables">
|
||||
<h1>Indices and tables<a class="headerlink" href="#indices-and-tables" title="Permalink to this headline">¶</a></h1>
|
||||
<ul class="simple">
|
||||
<li><p><a class="reference internal" href="genindex.html"><span class="std std-ref">Index</span></a></p></li>
|
||||
<li><p><a class="reference internal" href="py-modindex.html"><span class="std std-ref">Module Index</span></a></p></li>
|
||||
<li><p><a class="reference internal" href="search.html"><span class="std std-ref">Search Page</span></a></p></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
@@ -343,7 +333,7 @@ Alternatively, you can also run the command line utility like this: <code class=
|
||||
<h3 id="searchlabel">Quick search</h3>
|
||||
<div class="searchformwrapper">
|
||||
<form class="search" action="search.html" method="get">
|
||||
<input type="text" name="q" aria-labelledby="searchlabel" />
|
||||
<input type="text" name="q" aria-labelledby="searchlabel" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"/>
|
||||
<input type="submit" value="Go" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -365,7 +355,7 @@ Alternatively, you can also run the command line utility like this: <code class=
|
||||
©2021, Rhet Turnbull.
|
||||
|
||||
|
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||
|
||||
|
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>osxphotos — osxphotos 0.41.2 documentation</title>
|
||||
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta name="generator" content="Docutils 0.17.1: http://docutils.sourceforge.net/" />
|
||||
|
||||
<title>osxphotos — osxphotos 0.47.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
<script src="_static/jquery.js"></script>
|
||||
<script src="_static/underscore.js"></script>
|
||||
<script src="_static/doctools.js"></script>
|
||||
@@ -30,11 +31,11 @@
|
||||
|
||||
<div class="body" role="main">
|
||||
|
||||
<div class="section" id="osxphotos">
|
||||
<section id="osxphotos">
|
||||
<h1>osxphotos<a class="headerlink" href="#osxphotos" title="Permalink to this headline">¶</a></h1>
|
||||
<div class="toctree-wrapper compound">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
@@ -69,7 +70,7 @@
|
||||
<h3 id="searchlabel">Quick search</h3>
|
||||
<div class="searchformwrapper">
|
||||
<form class="search" action="search.html" method="get">
|
||||
<input type="text" name="q" aria-labelledby="searchlabel" />
|
||||
<input type="text" name="q" aria-labelledby="searchlabel" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"/>
|
||||
<input type="submit" value="Go" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -91,7 +92,7 @@
|
||||
©2021, Rhet Turnbull.
|
||||
|
||||
|
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||
|
||||
|
|
||||
|
||||
BIN
docs/objects.inv
BIN
docs/objects.inv
Binary file not shown.
Binary file not shown.
1219
docs/reference.html
1219
docs/reference.html
File diff suppressed because one or more lines are too long
@@ -5,11 +5,11 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Search — osxphotos 0.41.2 documentation</title>
|
||||
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
|
||||
<title>Search — osxphotos 0.47.1 documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="_static/pygments.css" />
|
||||
<link rel="stylesheet" type="text/css" href="_static/alabaster.css" />
|
||||
|
||||
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
|
||||
<script data-url_root="./" id="documentation_options" src="_static/documentation_options.js"></script>
|
||||
<script src="_static/jquery.js"></script>
|
||||
<script src="_static/underscore.js"></script>
|
||||
<script src="_static/doctools.js"></script>
|
||||
@@ -37,26 +37,35 @@
|
||||
<div class="body" role="main">
|
||||
|
||||
<h1 id="search-documentation">Search</h1>
|
||||
<div id="fallback" class="admonition warning">
|
||||
<script>$('#fallback').hide();</script>
|
||||
|
||||
<noscript>
|
||||
<div class="admonition warning">
|
||||
<p>
|
||||
Please activate JavaScript to enable the search
|
||||
functionality.
|
||||
</p>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
|
||||
<p>
|
||||
Searching for multiple words only shows matches that contain
|
||||
all words.
|
||||
</p>
|
||||
|
||||
|
||||
<form action="" method="get">
|
||||
<input type="text" name="q" aria-labelledby="search-documentation" value="" />
|
||||
<input type="text" name="q" aria-labelledby="search-documentation" value="" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"/>
|
||||
<input type="submit" value="search" />
|
||||
<span id="search-progress" style="padding-left: 10px"></span>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
<div id="search-results">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -102,7 +111,7 @@
|
||||
©2021, Rhet Turnbull.
|
||||
|
||||
|
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 4.3.1</a>
|
||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
367
docs/tutorial.html
Normal file
367
docs/tutorial.html
Normal file
@@ -0,0 +1,367 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Export your photos — osxphotos 0.42.20 documentation</title>
|
||||
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
|
||||
<script src="_static/jquery.js"></script>
|
||||
<script src="_static/underscore.js"></script>
|
||||
<script src="_static/doctools.js"></script>
|
||||
<link rel="index" title="Index" href="genindex.html" />
|
||||
<link rel="search" title="Search" href="search.html" />
|
||||
|
||||
<link rel="stylesheet" href="_static/custom.css" type="text/css" />
|
||||
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9" />
|
||||
|
||||
</head><body>
|
||||
|
||||
|
||||
<div class="document">
|
||||
<div class="documentwrapper">
|
||||
<div class="bodywrapper">
|
||||
|
||||
|
||||
<div class="body" role="main">
|
||||
|
||||
<!-- OSXPHOTOS-TUTORIAL-HEADER:START -->
|
||||
# OSXPhotos Tutorial
|
||||
|
||||
## Tutorial
|
||||
<!-- OSXPHOTOS-TUTORIAL-HEADER:END --><p>The design philosophy for osxphotos is “make the easy things easy and make the hard things possible”. To “make the hard things possible”, osxphotos is very flexible and has many, many configuration options – the <code class="docutils literal notranslate"><span class="pre">export</span></code> command for example, has over 100 command line options. Thus, osxphotos may seem daunting at first. The purpose of this tutorial is to explain a number of common use cases with examples and, hopefully, make osxphotos less daunting to use. osxphotos includes several commands for retrieving information from your Photos library but the one most users are interested in is the <code class="docutils literal notranslate"><span class="pre">export</span></code> command which exports photos from the library so that’s the focus of this tutorial.</p>
|
||||
<div class="section" id="export-your-photos">
|
||||
<h1>Export your photos<a class="headerlink" href="#export-your-photos" title="Permalink to this headline">¶</a></h1>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span></code></p>
|
||||
<p>This command exports all your photos to the <code class="docutils literal notranslate"><span class="pre">/path/to/export</span></code> directory.</p>
|
||||
<p><strong>Note</strong>: osxphotos uses the term ‘photo’ to refer to a generic media asset in your Photos Library. A photo may be an image, a video file, a combination of still image and video file (e.g. an Apple “Live Photo” which is an image and an associated “live preview” video file), a JPEG image with an associated RAW image, etc.</p>
|
||||
</div>
|
||||
<div class="section" id="export-by-date">
|
||||
<h1>Export by date<a class="headerlink" href="#export-by-date" title="Permalink to this headline">¶</a></h1>
|
||||
<p>While the previous command will export all your photos (and videos–see note above), it probably doesn’t do exactly what you want. In the previous example, all the photos will be exported to a single folder: <code class="docutils literal notranslate"><span class="pre">/path/to/export</span></code>. If you have a large library with thousands of images and videos, this likely isn’t very useful. You can use the <code class="docutils literal notranslate"><span class="pre">--export-by-date</span></code> option to export photos to a folder structure organized by year, month, day, e.g. <code class="docutils literal notranslate"><span class="pre">2021/04/21</span></code>:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--export-by-date</span></code></p>
|
||||
<p>With this command, a photo that was created on 31 May 2015 would be exported to: <code class="docutils literal notranslate"><span class="pre">/path/to/export/2015/05/31</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="specify-directory-structure">
|
||||
<h1>Specify directory structure<a class="headerlink" href="#specify-directory-structure" title="Permalink to this headline">¶</a></h1>
|
||||
<p>If you prefer a different directory structure for your exported images, osxphotos provides a very flexible <span class="raw-html-m2r"><!-- OSXPHOTOS-TEMPLATE-SYSTEM-LINK:START --></span>template system<span class="raw-html-m2r"><!-- OSXPHOTOS-TEMPLATE-SYSTEM-LINK:END --></span> that allows you to specify the directory structure using the <code class="docutils literal notranslate"><span class="pre">--directory</span></code> option. For example, this command exported to a directory structure that looks like: <code class="docutils literal notranslate"><span class="pre">2015/May</span></code> (4-digit year / month name):</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--directory</span> <span class="pre">"{created.year}/{created.month}"</span></code></p>
|
||||
<p>The string following <code class="docutils literal notranslate"><span class="pre">--directory</span></code> is an <code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">template</span> <span class="pre">string</span></code>. Template strings are widely used throughout osxphotos and it’s worth your time to learn more about them. In a template string, the values between the curly braces, e.g. <code class="docutils literal notranslate"><span class="pre">{created.year}</span></code> are replaced with metadata from the photo being exported. In this case, <code class="docutils literal notranslate"><span class="pre">{created.year}</span></code> is the 4-digit year of the photo’s creation date and <code class="docutils literal notranslate"><span class="pre">{created.month}</span></code> is the full month name in the user’s locale (e.g. <code class="docutils literal notranslate"><span class="pre">May</span></code>, <code class="docutils literal notranslate"><span class="pre">mai</span></code>, etc.). In the osxphotos template system these are referred to as template fields. The text not included between <code class="docutils literal notranslate"><span class="pre">{}</span></code> pairs is interpreted literally, in this case <code class="docutils literal notranslate"><span class="pre">/</span></code>, is a directory separator.</p>
|
||||
<p>osxphotos provides access to almost all the metadata known to Photos about your images. For example, Photos performs reverse geolocation lookup on photos that contain GPS coordinates to assign place names to the photo. Using the <code class="docutils literal notranslate"><span class="pre">--directory</span></code> template, you could thus export photos organized by country name:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--directory</span> <span class="pre">"{created.year}/{place.name.country}"</span></code></p>
|
||||
<p>Of course, some photos might not have an associated place name so the template system allows you specify a default value to use if a template field is null (has no value).</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--directory</span> <span class="pre">"{created.year}/{place.name.country,No-Country}"</span></code></p>
|
||||
<p>The value after the ‘,’ in the template string is the default value, in this case ‘No-Country’. <strong>Note</strong>: If you don’t specify a default value and a template field is null, osxphotos will use “_” (underscore character) as the default.</p>
|
||||
<p>Some template fields, such as <code class="docutils literal notranslate"><span class="pre">{keyword}</span></code>, may expand to more than one value. For example, if a photo has keywords of “Travel” and “Vacation”, <code class="docutils literal notranslate"><span class="pre">{keyword}</span></code> would expand to “Travel”, “Vacation”. When used with <code class="docutils literal notranslate"><span class="pre">--directory</span></code>, this would result in the photo being exported to more than one directory (thus more than one copy of the photo would be exported). For example, if <code class="docutils literal notranslate"><span class="pre">IMG_1234.JPG</span></code> has keywords <code class="docutils literal notranslate"><span class="pre">Travel</span></code>, and <code class="docutils literal notranslate"><span class="pre">Vacation</span></code> and you run the following command:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--directory</span> <span class="pre">"{keyword}"</span></code></p>
|
||||
<p>the exported files would be:</p>
|
||||
<div class="highlight-default notranslate"><div class="highlight"><pre><span></span><span class="o">/</span><span class="n">path</span><span class="o">/</span><span class="n">to</span><span class="o">/</span><span class="n">export</span><span class="o">/</span><span class="n">Travel</span><span class="o">/</span><span class="n">IMG_1234</span><span class="o">.</span><span class="n">JPG</span>
|
||||
<span class="o">/</span><span class="n">path</span><span class="o">/</span><span class="n">to</span><span class="o">/</span><span class="n">export</span><span class="o">/</span><span class="n">Vacation</span><span class="o">/</span><span class="n">IMG_1234</span><span class="o">.</span><span class="n">JPG</span>
|
||||
</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="specify-exported-filename">
|
||||
<h1>Specify exported filename<a class="headerlink" href="#specify-exported-filename" title="Permalink to this headline">¶</a></h1>
|
||||
<p>By default, osxphotos will use the original filename of the photo when exporting. That is, the filename the photo had when it was taken or imported into Photos. This is often something like <code class="docutils literal notranslate"><span class="pre">IMG_1234.JPG</span></code> or <code class="docutils literal notranslate"><span class="pre">DSC05678.dng</span></code>. osxphotos allows you to specify a custom filename template using the <code class="docutils literal notranslate"><span class="pre">--filename</span></code> option in the same way as <code class="docutils literal notranslate"><span class="pre">--directory</span></code> allows you to specify a custom directory name. For example, Photos allows you specify a title or caption for a photo and you can use this in place of the original filename:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--filename</span> <span class="pre">"{title}"</span></code></p>
|
||||
<p>The above command will export photos using the title. Note that you don’t need to specify the extension as part of the <code class="docutils literal notranslate"><span class="pre">--filename</span></code> template as osxphotos will automatically add the correct file extension. Some photos might not have a title so in this case, you could use the default value feature to specify a different name for these photos. For example, to use the title as the filename, but if no title is specified, use the original filename instead:</p>
|
||||
<div class="highlight-txt notranslate"><div class="highlight"><pre><span></span>osxphotos export /path/to/export --filename "{title,{original_name}}"
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
Use photo's title as the filename <──────┘ ││ │
|
||||
││ │
|
||||
Value after comma will be used <───────┘│ │
|
||||
if title is blank │ │
|
||||
│ │
|
||||
The default value can be <────┘ │
|
||||
another template field │
|
||||
│
|
||||
Use photo's original name if no title <──────┘
|
||||
</pre></div>
|
||||
</div>
|
||||
<p>The osxphotos template system also allows for limited conditional logic of the type “If a condition is true then do one thing, otherwise, do a different thing”. For example, you can use the <code class="docutils literal notranslate"><span class="pre">--filename</span></code> option to name files that are marked as “Favorites” in Photos differently than other files. For example, to add a “#” to the name of every photo that’s a favorite:</p>
|
||||
<div class="highlight-txt notranslate"><div class="highlight"><pre><span></span>osxphotos export /path/to/export --filename "{original_name}{favorite?#,}"
|
||||
│ │ │││
|
||||
│ │ │││
|
||||
Use photo's original name as filename <──┘ │ │││
|
||||
│ │││
|
||||
'favorite' is True if photo is a Favorite, <───────┘ │││
|
||||
otherwise, False │││
|
||||
│││
|
||||
'?' specifies a conditional <─────────────┘││
|
||||
││
|
||||
Value immediately following ? will be used if <──────┘│
|
||||
preceding template field is True or non-blank │
|
||||
│
|
||||
Value immediately following comma will be used if <──────┘
|
||||
template field is False or blank (null); in this case
|
||||
no value is specified so a blank string "" will be used
|
||||
</pre></div>
|
||||
</div>
|
||||
<p>Like with <code class="docutils literal notranslate"><span class="pre">--directory</span></code>, using a multi-valued template field such as <code class="docutils literal notranslate"><span class="pre">{keyword}</span></code> may result in more than one copy of a photo being exported. For example, if <code class="docutils literal notranslate"><span class="pre">IMG_1234.JPG</span></code> has keywords <code class="docutils literal notranslate"><span class="pre">Travel</span></code>, and <code class="docutils literal notranslate"><span class="pre">Vacation</span></code> and you run the following command:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--filename</span> <span class="pre">"{keyword}-{original_name}"</span></code></p>
|
||||
<p>the exported files would be:</p>
|
||||
<div class="highlight-default notranslate"><div class="highlight"><pre><span></span><span class="o">/</span><span class="n">path</span><span class="o">/</span><span class="n">to</span><span class="o">/</span><span class="n">export</span><span class="o">/</span><span class="n">Travel</span><span class="o">-</span><span class="n">IMG_1234</span><span class="o">.</span><span class="n">JPG</span>
|
||||
<span class="o">/</span><span class="n">path</span><span class="o">/</span><span class="n">to</span><span class="o">/</span><span class="n">export</span><span class="o">/</span><span class="n">Vacation</span><span class="o">-</span><span class="n">IMG_1234</span><span class="o">.</span><span class="n">JPG</span>
|
||||
</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="edited-photos">
|
||||
<h1>Edited photos<a class="headerlink" href="#edited-photos" title="Permalink to this headline">¶</a></h1>
|
||||
<p>If a photo has been edited in Photos (e.g. cropped, adjusted, etc.) there will be both an original image and an edited image in the Photos Library. By default, osxphotos will export both the original and the edited image. To distinguish between them, osxphotos will append “_edited” to the edited image. For example, if the original image was named <code class="docutils literal notranslate"><span class="pre">IMG_1234.JPG</span></code>, osxphotos will export the original as <code class="docutils literal notranslate"><span class="pre">IMG_1234.JPG</span></code> and the edited version as <code class="docutils literal notranslate"><span class="pre">IMG_1234_edited.jpeg</span></code>. <strong>Note:</strong> Photos changes the extension of edited images to “.jpeg” even if the original was named “.JPG”. You can change the suffix appended to edited images using the <code class="docutils literal notranslate"><span class="pre">--edited-suffix</span></code> option:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--edited-suffix</span> <span class="pre">"_EDIT"</span></code></p>
|
||||
<p>In this example, the edited image would be named <code class="docutils literal notranslate"><span class="pre">IMG_1234_EDIT.jpeg</span></code>. Like many options in osxphotos, the <code class="docutils literal notranslate"><span class="pre">--edited-suffix</span></code> option can evaluate an osxphotos template string so you could append the modification date (the date the photo was edited) to all edited photos using this command:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--edited-suffix</span> <span class="pre">"_{modified.year}-{modified.mm}-{modified.dd}"</span></code></p>
|
||||
<p>In this example, if the photo was edited on 21 April 2021, the name of the exported file would be: <code class="docutils literal notranslate"><span class="pre">IMG_1234_2021-04-21.jpeg</span></code>.</p>
|
||||
<p>You can tell osxphotos to not export edited photos (that is, only export the original unedited photos) using <code class="docutils literal notranslate"><span class="pre">--skip-edited</span></code>:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--skip-edited</span></code></p>
|
||||
<p>You can also tell osxphotos to export either the original photo (if the photo has not been edited) or the edited photo (if it has been edited), but not both, using the <code class="docutils literal notranslate"><span class="pre">--skip-original-if-edited</span></code> option:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--skip-original-if-edited</span></code></p>
|
||||
<p>As mentioned above, Photos renames JPEG images that have been edited with the “.jpeg” extension. Some applications use “.JPG” and others use “.jpg” or “.JPEG”. You can use the <code class="docutils literal notranslate"><span class="pre">--jpeg-ext</span></code> option to have osxphotos rename all JPEG files with the same extension. Valid values are jpeg, jpg, JPEG, JPG; e.g. <code class="docutils literal notranslate"><span class="pre">--jpeg-ext</span> <span class="pre">jpg</span></code> to use ‘.jpg’ for all JPEGs.</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--jpeg-ext</span> <span class="pre">jpg</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="specifying-the-photos-library">
|
||||
<h1>Specifying the Photos library<a class="headerlink" href="#specifying-the-photos-library" title="Permalink to this headline">¶</a></h1>
|
||||
<p>All the above commands operate on the default Photos library. Most users only use a single Photos library which is also known as the System Photo Library. It is possible to use Photos with more than one library. For example, if you hold down the “Option” key while opening Photos, you can select an alternate Photos library. If you don’t specify which library to use, osxphotos will try find the last opened library. Occasionally it can’t determine this and in that case, it will use the System Photos Library. If you use more than one Photos library and want to explicitly specify which library to use, you can do so with the <code class="docutils literal notranslate"><span class="pre">--db</span></code> option. (db is short for database and is so named because osxphotos operates on the database that Photos uses to manage your Photos library).</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--db</span> <span class="pre">~/Pictures/MyAlternateLibrary.photoslibrary</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="missing-photos">
|
||||
<h1>Missing photos<a class="headerlink" href="#missing-photos" title="Permalink to this headline">¶</a></h1>
|
||||
<p>osxphotos works by copying photos out of the Photos library folder to export them. You may see osxphotos report that one or more photos are missing and thus could not be exported. One possible reason for this is that you are using iCloud to synch your Photos library and Photos either hasn’t yet synched the cloud library to the local Mac or you have Photos configured to “Optimize Mac Storage” in Photos Preferences. Another reason is that even if you have Photos configured to download originals to the Mac, Photos does not always download photos from shared albums or original screenshots to the Mac.</p>
|
||||
<p>If you encounter missing photos you can tell osxphotos to download the missing photos from iCloud using the <code class="docutils literal notranslate"><span class="pre">--download-missing</span></code> option. <code class="docutils literal notranslate"><span class="pre">--download-missing</span></code> uses AppleScript to communicate with Photos and tell it to download the missing photos. Photos’ AppleScript interface is somewhat buggy and you may find that Photos crashes. In this case, osxphotos will attempt to restart Photos to resume the download process. There’s also an experimental <code class="docutils literal notranslate"><span class="pre">--use-photokit</span></code> option that will communicate with Photos using a different “PhotoKit” interface. This option must be used together with <code class="docutils literal notranslate"><span class="pre">--download-missing</span></code>:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--download-missing</span></code></p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--download-missing</span> <span class="pre">--use-photokit</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="exporting-to-external-disks">
|
||||
<h1>Exporting to external disks<a class="headerlink" href="#exporting-to-external-disks" title="Permalink to this headline">¶</a></h1>
|
||||
<p>If you are exporting to an external network attached storage (NAS) device, you may encounter errors if the network connection is unreliable. In this case, you can use the <code class="docutils literal notranslate"><span class="pre">--retry</span></code> option so that osxphotos will automatically retry the export. Use <code class="docutils literal notranslate"><span class="pre">--retry</span></code> with a number that specifies the number of times to retry the export:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--retry</span> <span class="pre">3</span></code></p>
|
||||
<p>In this example, osxphotos will attempt to export a photo up to 3 times if it encounters an error.</p>
|
||||
</div>
|
||||
<div class="section" id="exporting-metadata-with-exported-photos">
|
||||
<h1>Exporting metadata with exported photos<a class="headerlink" href="#exporting-metadata-with-exported-photos" title="Permalink to this headline">¶</a></h1>
|
||||
<p>Photos tracks a tremendous amount of metadata associated with photos in the library such as keywords, faces and persons, reverse geolocation data, and image classification labels. Photos’ native export capability does not preserve most of this metadata. osxphotos can, however, access and preserve almost all the metadata associated with photos. Using the free <cite>``exiftool`</cite> <<a class="reference external" href="https://exiftool.org/">https://exiftool.org/</a>>`_ app, osxphotos can write metadata to exported photos. Follow the instructions on the exiftool website to install exiftool then you can use the <code class="docutils literal notranslate"><span class="pre">--exiftool</span></code> option to write metadata to exported photos:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--exiftool</span></code></p>
|
||||
<p>This will write basic metadata such as keywords, persons, and GPS location to the exported files. osxphotos includes several additional options that can be used in conjunction with <code class="docutils literal notranslate"><span class="pre">--exiftool</span></code> to modify the metadata that is written by <code class="docutils literal notranslate"><span class="pre">exiftool</span></code>. For example, you can use the <code class="docutils literal notranslate"><span class="pre">--keyword-template</span></code> option to specify custom keywords (again, via the osxphotos template system). For example, to use the folder and album a photo is in to create hierarchal keywords in the format used by Lightroom Classic:</p>
|
||||
<div class="highlight-txt notranslate"><div class="highlight"><pre><span></span>osxphotos export /path/to/export --exiftool --keyword-template "{folder_album(>)}"
|
||||
│ │
|
||||
│ │
|
||||
folder_album results in the folder(s) <──┘ │
|
||||
and album a photo is contained in │
|
||||
│
|
||||
The value in () is used as the path separator <───────┘
|
||||
for joining the folders and albums. For example,
|
||||
if photo is in Folder1/Folder2/Album, (>) produces
|
||||
"Folder1>Folder2>Album" which some programs, such as
|
||||
Lightroom Classic, treat as hierarchal keywords
|
||||
</pre></div>
|
||||
</div>
|
||||
<p>The above command will write all the regular metadata that <code class="docutils literal notranslate"><span class="pre">--exiftool</span></code> normally writes to the file upon export but will also add an additional keyword in the exported metadata in the form “Folder1>Folder2>Album”. If you did not include the <code class="docutils literal notranslate"><span class="pre">(>)</span></code> in the template string (e.g. <code class="docutils literal notranslate"><span class="pre">{folder_album}</span></code>), folder_album would render in form “Folder1/Folder2/Album”.</p>
|
||||
<p>A powerful feature of Photos is that it uses machine learning algorithms to automatically classify or label photos. These labels are used when you search for images in Photos but are not otherwise available to the user. osxphotos is able to read all the labels associated with a photo and makes those available through the template system via the <code class="docutils literal notranslate"><span class="pre">{label}</span></code>. Think of these as automatic keywords as opposed to the keywords you assign manually in Photos. One common use case is to use the automatic labels to create new keywords when exporting images so that these labels are embedded in the image’s metadata:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--exiftool</span> <span class="pre">--keyword-template</span> <span class="pre">"{label}"</span></code></p>
|
||||
<p><strong>Note</strong>: When evaluating templates for <code class="docutils literal notranslate"><span class="pre">--directory</span></code> and <code class="docutils literal notranslate"><span class="pre">--filename</span></code>, osxphotos inserts the automatic default value “_” for any template field which is null (empty or blank). This is to ensure that there’s never a null directory or filename created. For metadata templates such as <code class="docutils literal notranslate"><span class="pre">--keyword-template</span></code>, osxphotos does not provide an automatic default value thus if the template field is null, no keyword would be created. Of course, you can provide a default value if desired and osxphotos will use this. For example, to add “nolabel” as a keyword for any photo that doesn’t have labels:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--exiftool</span> <span class="pre">--keyword-template</span> <span class="pre">"{label,nolabel}"</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="sidecar-files">
|
||||
<h1>Sidecar files<a class="headerlink" href="#sidecar-files" title="Permalink to this headline">¶</a></h1>
|
||||
<p>Another way to export metadata about your photos is through the use of sidecar files. These are files that have the same name as your photo (but with a different extension) and carry the metadata. Many digital asset management applications (for example, PhotoPrism, Lightroom, Digikam, etc.) can read or write sidecar files. osxphotos can export metadata in exiftool compatible JSON and XMP formats using the <code class="docutils literal notranslate"><span class="pre">--sidecar</span></code> option. For example, to output metadata to XMP sidecars:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--sidecar</span> <span class="pre">XMP</span></code></p>
|
||||
<p>Unlike <code class="docutils literal notranslate"><span class="pre">--exiftool</span></code>, you do not need to install exiftool to use the <code class="docutils literal notranslate"><span class="pre">--sidecar</span></code> feature. Many of the same configuration options that apply to <code class="docutils literal notranslate"><span class="pre">--exiftool</span></code> to modify metadata, for example, <code class="docutils literal notranslate"><span class="pre">--keyword-template</span></code> can also be used with <code class="docutils literal notranslate"><span class="pre">--sidecar</span></code>.</p>
|
||||
<p>Sidecar files are named “photoname.ext.sidecar_ext”. For example, if the photo is named <code class="docutils literal notranslate"><span class="pre">IMG_1234.JPG</span></code> and the sidecar format is XMP, the sidecar would be named <code class="docutils literal notranslate"><span class="pre">IMG_1234.JPG.XMP</span></code>. Some applications expect the sidecar in this case to be named <code class="docutils literal notranslate"><span class="pre">IMG_1234.XMP</span></code>. You can use the <code class="docutils literal notranslate"><span class="pre">-sidecar-drop-ext</span></code> option to force osxphotos to name the sidecar files in this manner:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--sidecar</span> <span class="pre">XMP</span> <span class="pre">-sidecar-drop-ext</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="updating-a-previous-export">
|
||||
<h1>Updating a previous export<a class="headerlink" href="#updating-a-previous-export" title="Permalink to this headline">¶</a></h1>
|
||||
<p>If you want to use osxphotos to perform periodic backups of your Photos library rather than a one-time export, use the <code class="docutils literal notranslate"><span class="pre">--update</span></code> option. When <code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span></code> is run, it creates a database file named <code class="docutils literal notranslate"><span class="pre">.osxphotos_export.db</span></code> in the export folder. (<strong>Note</strong> Because the filename starts with a “.”, you won’t see it in Finder which treats “dot-files” like this as hidden. You will see the file in the Terminal.) . If you run osxphotos with the <code class="docutils literal notranslate"><span class="pre">--update</span></code> option, it will look for this database file and, if found, use it to retrieve state information from the last time it was run to only export new or changed files. For example:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--update</span></code></p>
|
||||
<p>will read the export database located in <code class="docutils literal notranslate"><span class="pre">/path/to/export/.osxphotos_export.db</span></code> and only export photos that have been added or changed since the last time osxphotos was run. You can run osxphotos with the <code class="docutils literal notranslate"><span class="pre">--update</span></code> option even if it’s never been run before. If the database isn’t found, osxphotos will create it. If you run <code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span></code> without <code class="docutils literal notranslate"><span class="pre">--update</span></code> in a folder where you had previously exported photos, it will re-export all the photos. If your intent is to keep a periodic backup of your Photos Library up to date with osxphotos, you should always use <code class="docutils literal notranslate"><span class="pre">--update</span></code>.</p>
|
||||
<p>If your workflow involves moving files out of the export directory (for example, you move them into a digital asset management app) but you want to use the features of <code class="docutils literal notranslate"><span class="pre">--update</span></code>, you can use the <code class="docutils literal notranslate"><span class="pre">--only-new</span></code> with <code class="docutils literal notranslate"><span class="pre">--update</span></code> to force osxphotos to only export photos that are new (added to the library) since the last update. In this case, osxphotos will ignore the previously exported files that are now missing. Without <code class="docutils literal notranslate"><span class="pre">--only-new</span></code>, osxphotos would see that previously exported files are missing and re-export them.</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--update</span> <span class="pre">--only-new</span></code></p>
|
||||
<p>If your workflow involves editing the images you exported from Photos but you still want to maintain a backup with <code class="docutils literal notranslate"><span class="pre">--update</span></code>, you should use the <code class="docutils literal notranslate"><span class="pre">--ignore-signature</span></code> option. <code class="docutils literal notranslate"><span class="pre">--ignore-signature</span></code> instructs osxphotos to ignore the file’s signature (for example, size and date modified) when deciding which files should be updated with <code class="docutils literal notranslate"><span class="pre">--update</span></code>. If you edit a file in the export directory and then run <code class="docutils literal notranslate"><span class="pre">--update</span></code> without <code class="docutils literal notranslate"><span class="pre">--ignore-signature</span></code>, osxphotos will see that the file is different than the one in the Photos library and re-export it.</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--update</span> <span class="pre">--ignore-signature</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="dry-run">
|
||||
<h1>Dry Run<a class="headerlink" href="#dry-run" title="Permalink to this headline">¶</a></h1>
|
||||
<p>You can use the <code class="docutils literal notranslate"><span class="pre">--dry-run</span></code> option to have osxphotos “dry run” or test an export without actually exporting any files. When combined with the <code class="docutils literal notranslate"><span class="pre">--verbose</span></code> option, which causes osxphotos to print out details of every file being exported, this can be a useful tool for testing your export options before actually running a full export. For example, if you are learning the template system and want to verify that your <code class="docutils literal notranslate"><span class="pre">--directory</span></code> and <code class="docutils literal notranslate"><span class="pre">--filename</span></code> templates are correct, <code class="docutils literal notranslate"><span class="pre">--dry-run</span> <span class="pre">--verbose</span></code> will print out the name of each file being exported.</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--dry-run</span> <span class="pre">--verbose</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="creating-a-report-of-all-exported-files">
|
||||
<h1>Creating a report of all exported files<a class="headerlink" href="#creating-a-report-of-all-exported-files" title="Permalink to this headline">¶</a></h1>
|
||||
<p>You can use the <code class="docutils literal notranslate"><span class="pre">--report</span></code> option to create a report, in comma-separated values (CSV) format that will list the details of all files that were exported, skipped, missing, etc. This file format is compatible with programs such as Microsoft Excel. Provide the name of the report after the <code class="docutils literal notranslate"><span class="pre">--report</span></code> option:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--report</span> <span class="pre">export.csv</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="exporting-only-certain-photos">
|
||||
<h1>Exporting only certain photos<a class="headerlink" href="#exporting-only-certain-photos" title="Permalink to this headline">¶</a></h1>
|
||||
<p>By default, osxphotos will export your entire Photos library. If you want to export only certain photos, osxphotos provides a rich set of “query options” that allow you to query the Photos database to filter out only certain photos that match your query criteria. The tutorial does not cover all the query options as there are over 50 of them–read the help text (<code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">help</span> <span class="pre">export</span></code>) to better understand the available query options. No matter which subset of photos you would like to export, there is almost certainly a way for osxphotos to filter these. For example, you can filter for only images that contain certain keywords or images without a title, images from a specific time of day or specific date range, images contained in specific albums, etc.</p>
|
||||
<p>For example, to export only photos with keyword <code class="docutils literal notranslate"><span class="pre">Travel</span></code>:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--keyword</span> <span class="pre">"Travel"</span></code></p>
|
||||
<p>Like many options in osxphotos, <code class="docutils literal notranslate"><span class="pre">--keyword</span></code> (and most other query options) can be repeated to search for more than one term. For example, to find photos with keyword <code class="docutils literal notranslate"><span class="pre">Travel</span></code> <em>or</em> keyword <code class="docutils literal notranslate"><span class="pre">Vacation</span></code>:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--keyword</span> <span class="pre">"Travel"</span> <span class="pre">--keyword</span> <span class="pre">"Vacation"</span></code></p>
|
||||
<p>To export only photos contained in the album “Summer Vacation”:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--album</span> <span class="pre">"Summer</span> <span class="pre">Vacation"</span></code></p>
|
||||
<p>There are also a number of query options to export only certain types of photos. For example, to export only photos taken with iPhone “Portrait Mode”:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--portrait</span></code></p>
|
||||
<p>You can also export photos in a certain date range:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--from-date</span> <span class="pre">"2020-01-01"</span> <span class="pre">--to-date</span> <span class="pre">"2020-02-28"</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="converting-images-to-jpeg-on-export">
|
||||
<h1>Converting images to JPEG on export<a class="headerlink" href="#converting-images-to-jpeg-on-export" title="Permalink to this headline">¶</a></h1>
|
||||
<p>Photos can store images in many different formats. osxphotos can convert non-JPEG images (for example, RAW photos) to JPEG on export using the <code class="docutils literal notranslate"><span class="pre">--convert-to-jpeg</span></code> option. You can specify the JPEG quality (0: worst, 1.0: best) using <code class="docutils literal notranslate"><span class="pre">--jpeg-quality</span></code>. For example:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--convert-to-jpeg</span> <span class="pre">--jpeg-quality</span> <span class="pre">0.9</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="finder-attributes">
|
||||
<h1>Finder attributes<a class="headerlink" href="#finder-attributes" title="Permalink to this headline">¶</a></h1>
|
||||
<p>In addition to using <code class="docutils literal notranslate"><span class="pre">exiftool</span></code> to write metadata directly to the image metadata, osxphotos can write certain metadata that is available to the Finder and Spotlight but does not modify the actual image file. This is done through something called extended attributes which are stored in the filesystem with a file but do not actually modify the file itself. Finder tags and Finder comments are common examples of these.</p>
|
||||
<p>osxphotos can, for example, write any keywords in the image to Finder tags so that you can search for images in Spotlight or the Finder using the <code class="docutils literal notranslate"><span class="pre">tag:tagname</span></code> syntax:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--finder-tag-keywords</span></code></p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">--finder-tag-keywords</span></code> also works with <code class="docutils literal notranslate"><span class="pre">--keyword-template</span></code> as described above in the section on <code class="docutils literal notranslate"><span class="pre">exiftool</span></code>:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--finder-tag-keywords</span> <span class="pre">--keyword-template</span> <span class="pre">"{label}"</span></code></p>
|
||||
<p>The <code class="docutils literal notranslate"><span class="pre">--xattr-template</span></code> option allows you to set a variety of other extended attributes. It is used in the format <code class="docutils literal notranslate"><span class="pre">--xattr-template</span> <span class="pre">ATTRIBUTE</span> <span class="pre">TEMPLATE</span></code> where ATTRIBUTE is one of ‘authors’,’comment’, ‘copyright’, ‘description’, ‘findercomment’, ‘headline’, ‘keywords’.</p>
|
||||
<p>For example, to set Finder comment to the photo’s title and description:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--xattr-template</span> <span class="pre">findercomment</span> <span class="pre">"{title}{newline}{descr}"</span></code></p>
|
||||
<p>In the template string above, <code class="docutils literal notranslate"><span class="pre">{newline}</span></code> instructs osxphotos to insert a new line character (“n”) between the title and description. In this example, if <code class="docutils literal notranslate"><span class="pre">{title}</span></code> or <code class="docutils literal notranslate"><span class="pre">{descr}</span></code> is empty, you’ll get “titlen” or “ndescription” which may not be desired so you can use more advanced features of the template system to handle these cases:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--xattr-template</span> <span class="pre">findercomment</span> <span class="pre">"{title}{title?{descr?{newline},},}{descr}"</span></code></p>
|
||||
<p>Explanation of the template string:</p>
|
||||
<div class="highlight-txt notranslate"><div class="highlight"><pre><span></span>{title}{title?{descr?{newline},},}{descr}
|
||||
│ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
└──> insert title │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
└───> is there a title?
|
||||
│ │ │ │ │
|
||||
└───> if so, is there a description?
|
||||
│ │ │ │
|
||||
└───> if so, insert new line
|
||||
│ │ │
|
||||
└───> if descr is blank, insert nothing
|
||||
│ │
|
||||
└───> if title is blank, insert nothing
|
||||
│
|
||||
└───> finally, insert description
|
||||
</pre></div>
|
||||
</div>
|
||||
<p>In this example, <code class="docutils literal notranslate"><span class="pre">title?</span></code> demonstrates use of the boolean (True/False) feature of the template system. <code class="docutils literal notranslate"><span class="pre">title?</span></code> is read as “Is the title True (or not blank/empty)? If so, then the value immediately following the <code class="docutils literal notranslate"><span class="pre">?</span></code> is used in place of <code class="docutils literal notranslate"><span class="pre">title</span></code>. If <code class="docutils literal notranslate"><span class="pre">title</span></code> is blank, then the value immediately following the comma is used instead. The format for boolean fields is <code class="docutils literal notranslate"><span class="pre">field?value</span> <span class="pre">if</span> <span class="pre">true,value</span> <span class="pre">if</span> <span class="pre">false</span></code>. Either <code class="docutils literal notranslate"><span class="pre">value</span> <span class="pre">if</span> <span class="pre">true</span></code> or <code class="docutils literal notranslate"><span class="pre">value</span> <span class="pre">if</span> <span class="pre">false</span></code> may be blank, in which case a blank string (“”) is used for the value and both may also be an entirely new template string as seen in the above example. Using this format, template strings may be nested inside each other to form complex <code class="docutils literal notranslate"><span class="pre">if-then-else</span></code> statements.</p>
|
||||
<p>The above example, while complex to read, shows how flexible the osxphotos template system is. If you invest a little time learning how to use the template system you can easily handle almost any use case you have.</p>
|
||||
<p>See Extended Attributes section in the help for <code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span></code> for additional information about this feature.</p>
|
||||
</div>
|
||||
<div class="section" id="saving-and-loading-options">
|
||||
<h1>Saving and loading options<a class="headerlink" href="#saving-and-loading-options" title="Permalink to this headline">¶</a></h1>
|
||||
<p>If you repeatedly run a complex osxphotos export command (for example, to regularly back-up your Photos library), you can save all the options to a configuration file for future use (<code class="docutils literal notranslate"><span class="pre">--save-config</span> <span class="pre">FILE</span></code>) and then load them (<code class="docutils literal notranslate"><span class="pre">--load-config</span> <span class="pre">FILE</span></code>) instead of repeating each option on the command line.</p>
|
||||
<p>To save the configuration:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre"><all</span> <span class="pre">your</span> <span class="pre">options</span> <span class="pre">here></span> <span class="pre">--update</span> <span class="pre">--save-config</span> <span class="pre">osxphotos.toml</span></code></p>
|
||||
<p>Then the next to you run osxphotos, you can simply do this:</p>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">/path/to/export</span> <span class="pre">--load-config</span> <span class="pre">osxphotos.toml</span></code></p>
|
||||
<p>The configuration file is a plain text file in <a class="reference external" href="https://toml.io/en/">TOML</a> format so the <code class="docutils literal notranslate"><span class="pre">.toml</span></code> extension is standard but you can name the file anything you like.</p>
|
||||
</div>
|
||||
<div class="section" id="an-example-from-an-actual-osxphotos-user">
|
||||
<h1>An example from an actual osxphotos user<a class="headerlink" href="#an-example-from-an-actual-osxphotos-user" title="Permalink to this headline">¶</a></h1>
|
||||
<p>Here’s a comprehensive use case from an actual osxphotos user that integrates many of the concepts discussed in this tutorial (thank-you Philippe for contributing this!):</p>
|
||||
<div class="highlight-default notranslate"><div class="highlight"><pre><span></span>I usually import my iPhone’s photo roll on a more or less regular basis, and it
|
||||
includes photos and videos. As a result, the size ot my Photos library may rise
|
||||
very quickly. Nevertheless, I will tag and geolocate everything as Photos has a
|
||||
quite good keyword management system.
|
||||
|
||||
After a while, I want to take most of the videos out of the library and move them
|
||||
to a separate "videos" folder on a different folder / volume. As I might want to
|
||||
use them in Final Cut Pro, and since Final Cut is able to import Finder tags into
|
||||
its internal library tagging system, I will use osxphotos to do just this.
|
||||
|
||||
Picking the videos can be left to Photos, using a smart folder for instance. Then
|
||||
just add a keyword to all videos to be processed. Here I chose "Quik" as I wanted
|
||||
to spot all videos created on my iPhone using the Quik application (now part of
|
||||
GoPro).
|
||||
|
||||
I want to retrieve my keywords only and make sure they populate the Finder tags, as
|
||||
well as export all the persons identified in the videos by Photos. I also want to
|
||||
merge any keywords or persons already in the video metadata with the exported
|
||||
metadata.
|
||||
|
||||
Keeping Photo’s edited titles and descriptions and putting both in the Finder
|
||||
comments field in a readable manner is also enabled.
|
||||
|
||||
And I want to keep the file’s creation date (using `--touch-file`).
|
||||
|
||||
Finally, use `--strip` to remove any leading or trailing whitespace from processed
|
||||
template fields.
|
||||
</pre></div>
|
||||
</div>
|
||||
<p><code class="docutils literal notranslate"><span class="pre">osxphotos</span> <span class="pre">export</span> <span class="pre">~/Desktop/folder</span> <span class="pre">for</span> <span class="pre">exported</span> <span class="pre">videos/</span> <span class="pre">--keyword</span> <span class="pre">Quik</span> <span class="pre">--only-movies</span> <span class="pre">--db</span> <span class="pre">/path</span> <span class="pre">to</span> <span class="pre">my.photoslibrary</span> <span class="pre">--touch-file</span> <span class="pre">--finder-tag-keywords</span> <span class="pre">--person-keyword</span> <span class="pre">--xattr-template</span> <span class="pre">findercomment</span> <span class="pre">"{title}{title?{descr?{newline},},}{descr}"</span> <span class="pre">--exiftool-merge-keywords</span> <span class="pre">--exiftool-merge-persons</span> <span class="pre">--exiftool</span> <span class="pre">--strip</span></code></p>
|
||||
</div>
|
||||
<div class="section" id="conclusion">
|
||||
<h1>Conclusion<a class="headerlink" href="#conclusion" title="Permalink to this headline">¶</a></h1>
|
||||
<p>osxphotos is very flexible. If you merely want to backup your Photos library, then spending a few minutes to understand the <code class="docutils literal notranslate"><span class="pre">--directory</span></code> option is likely all you need and you can be up and running in minutes. However, if you have a more complex workflow, osxphotos likely provides options to implement your workflow. This tutorial does not attempt to cover every option offered by osxphotos but hopefully it provides a good understanding of what kinds of things are possible and where to explore if you want to learn more.</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="sphinxsidebar" role="navigation" aria-label="main navigation">
|
||||
<div class="sphinxsidebarwrapper">
|
||||
<h1 class="logo"><a href="index.html">osxphotos</a></h1>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<h3>Navigation</h3>
|
||||
<ul>
|
||||
<li class="toctree-l1"><a class="reference internal" href="cli.html">osxphotos command line interface (CLI)</a></li>
|
||||
<li class="toctree-l1"><a class="reference internal" href="reference.html">osxphotos package</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="relations">
|
||||
<h3>Related Topics</h3>
|
||||
<ul>
|
||||
<li><a href="index.html">Documentation overview</a><ul>
|
||||
</ul></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="searchbox" style="display: none" role="search">
|
||||
<h3 id="searchlabel">Quick search</h3>
|
||||
<div class="searchformwrapper">
|
||||
<form class="search" action="search.html" method="get">
|
||||
<input type="text" name="q" aria-labelledby="searchlabel" />
|
||||
<input type="submit" value="Go" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>$('#searchbox').show(0);</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearer"></div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
©2021, Rhet Turnbull.
|
||||
|
||||
|
|
||||
Powered by <a href="http://sphinx-doc.org/">Sphinx 3.5.2</a>
|
||||
& <a href="https://github.com/bitprophet/alabaster">Alabaster 0.7.12</a>
|
||||
|
||||
|
|
||||
<a href="_sources/tutorial.md.txt"
|
||||
rel="nofollow">Page source</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,4 +3,6 @@ osxphotos command line interface (CLI)
|
||||
|
||||
.. click:: osxphotos.cli:cli
|
||||
:prog: osxphotos
|
||||
:nested: full
|
||||
:nested: full
|
||||
|
||||
.. program-output:: python3 -m osxphotos --help
|
||||
@@ -16,7 +16,7 @@ import sys
|
||||
|
||||
import sphinx_rtd_theme
|
||||
|
||||
sys.path.insert(0, os.path.abspath(".."))
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
112
examples/album_sort_order.py
Normal file
112
examples/album_sort_order.py
Normal file
@@ -0,0 +1,112 @@
|
||||
""" Example function for use with osxphotos export --post-function option showing how to record album sort order """
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Optional
|
||||
|
||||
from osxphotos import ExportResults, PhotoInfo
|
||||
from osxphotos.albuminfo import AlbumInfo
|
||||
from osxphotos.path_utils import sanitize_dirname
|
||||
from osxphotos.phototemplate import RenderOptions
|
||||
|
||||
|
||||
def album_sequence(photo: PhotoInfo, options: RenderOptions, **kwargs) -> str:
|
||||
"""Call this with {function} template to get album sequence (sort order) when exporting with {folder_album} template
|
||||
|
||||
For example, calling this template function like the following prepends sequence#_ to each exported file if the file is in an album:
|
||||
|
||||
osxphotos export /path/to/export -V --directory "{folder_album}" --filename "{album?{function:examples/album_sort_order.py::album_sequence}_,}{original_name}"
|
||||
|
||||
The sequence will start at 0. To change the sequence to start at a different offset (e.g. 1), set the environment variable OSXPHOTOS_ALBUM_SEQUENCE_START=1 (or whatever offset you want)
|
||||
"""
|
||||
dest_path = options.dest_path
|
||||
if not dest_path:
|
||||
return ""
|
||||
|
||||
album_info = None
|
||||
for album in photo.album_info:
|
||||
# following code is how {folder_album} builds the folder path
|
||||
folder = "/".join(sanitize_dirname(f) for f in album.folder_names)
|
||||
folder += "/" + sanitize_dirname(album.title)
|
||||
if dest_path.endswith(folder):
|
||||
album_info = album
|
||||
break
|
||||
else:
|
||||
# didn't find the album, so skip this file
|
||||
return ""
|
||||
start_index = int(os.getenv("OSXPHOTOS_ALBUM_SEQUENCE_START", 0))
|
||||
return str(album_info.photo_index(photo) + start_index)
|
||||
|
||||
|
||||
def album_sort_order(
|
||||
photo: PhotoInfo, results: ExportResults, verbose: callable, **kwargs
|
||||
):
|
||||
"""Call this with osxphotos export /path/to/export --post-function album_sort_order.py::album_sort_order
|
||||
This will get called immediately after the photo has been exported
|
||||
|
||||
Args:
|
||||
photo: PhotoInfo instance for the photo that's just been exported
|
||||
results: ExportResults instance with information about the files associated with the exported photo
|
||||
verbose: A function to print verbose output if --verbose is set; if --verbose is not set, acts as a no-op (nothing gets printed)
|
||||
**kwargs: reserved for future use; recommend you include **kwargs so your function still works if additional arguments are added in future versions
|
||||
|
||||
Notes:
|
||||
Use verbose(str) instead of print if you want your function to conditionally output text depending on --verbose flag
|
||||
Any string printed with verbose that contains "warning" or "error" (case-insensitive) will be printed with the appropriate warning or error color
|
||||
Will not be called if --dry-run flag is enabled
|
||||
Will be called immediately after export and before any --post-command commands are executed
|
||||
"""
|
||||
|
||||
# ExportResults has the following properties
|
||||
# fields with filenames contain the full path to the file
|
||||
# exported: list of all files exported
|
||||
# new: list of all new files exported (--update)
|
||||
# updated: list of all files updated (--update)
|
||||
# skipped: list of all files skipped (--update)
|
||||
# exif_updated: list of all files that were updated with --exiftool
|
||||
# touched: list of all files that had date updated with --touch-file
|
||||
# converted_to_jpeg: list of files converted to jpeg with --convert-to-jpeg
|
||||
# sidecar_json_written: list of all JSON sidecar files written
|
||||
# sidecar_json_skipped: list of all JSON sidecar files skipped (--update)
|
||||
# sidecar_exiftool_written: list of all exiftool sidecar files written
|
||||
# sidecar_exiftool_skipped: list of all exiftool sidecar files skipped (--update)
|
||||
# sidecar_xmp_written: list of all XMP sidecar files written
|
||||
# sidecar_xmp_skipped: list of all XMP sidecar files skipped (--update)
|
||||
# missing: list of all missing files
|
||||
# error: list tuples of (filename, error) for any errors generated during export
|
||||
# exiftool_warning: list of tuples of (filename, warning) for any warnings generated by exiftool with --exiftool
|
||||
# exiftool_error: list of tuples of (filename, error) for any errors generated by exiftool with --exiftool
|
||||
# xattr_written: list of files that had extended attributes written
|
||||
# xattr_skipped: list of files that where extended attributes were skipped (--update)
|
||||
# deleted_files: list of deleted files
|
||||
# deleted_directories: list of deleted directories
|
||||
# exported_album: list of tuples of (filename, album_name) for exported files added to album with --add-exported-to-album
|
||||
# skipped_album: list of tuples of (filename, album_name) for skipped files added to album with --add-skipped-to-album
|
||||
# missing_album: list of tuples of (filename, album_name) for missing files added to album with --add-missing-to-album
|
||||
|
||||
for filepath in results.exported:
|
||||
# do your processing here
|
||||
filepath = pathlib.Path(filepath)
|
||||
album_dir = filepath.parent.name
|
||||
if album_dir not in photo.albums:
|
||||
return
|
||||
|
||||
# get the first album that matches this name of which the photo is a member
|
||||
album_info = None
|
||||
for album in photo.album_info:
|
||||
if album.title == album_dir:
|
||||
album_info = album
|
||||
break
|
||||
else:
|
||||
# didn't find the album, so skip this file
|
||||
return
|
||||
|
||||
try:
|
||||
sort_order = album_info.photo_index(photo)
|
||||
except ValueError:
|
||||
# photo not in album, so skip this file
|
||||
return
|
||||
|
||||
verbose(f"Sort order for {filepath} in album {album_dir} is {sort_order}")
|
||||
with open(str(filepath) + "_sort_order.txt", "w") as f:
|
||||
f.write(str(sort_order))
|
||||
173
examples/export_template.py
Normal file
173
examples/export_template.py
Normal file
@@ -0,0 +1,173 @@
|
||||
""" Example showing how to use a custom function for osxphotos {function} template
|
||||
to export photos in a folder structure similar to Photos' own structure
|
||||
|
||||
Use: osxphotos export /path/to/export --directory "{function:/path/to/export_template.py::photos_folders}"
|
||||
|
||||
This will likely export multiple copies of each photo. If using APFS file system, this should be
|
||||
a non-issue as osxphotos will use copy-on-write so each exported photo doesn't take up additional space
|
||||
unless you edit the photo.
|
||||
|
||||
Thank-you @mkirkland4874 for the inspiration for this example!
|
||||
|
||||
This will produce output similar to this:
|
||||
|
||||
Library
|
||||
- Photos
|
||||
-- {created.year}
|
||||
---- {created.mm}
|
||||
------ {created.dd}
|
||||
- Favorites
|
||||
- Hidden
|
||||
- Recently Deleted
|
||||
- People
|
||||
- Places
|
||||
- Imports
|
||||
Media Types
|
||||
- Videos
|
||||
- Selfies
|
||||
- Portrait
|
||||
- Panoramas
|
||||
- Time-lapse
|
||||
- Slow-mo
|
||||
- Bursts
|
||||
- Screenshots
|
||||
My Albums
|
||||
-- Album 1
|
||||
-- Album 2
|
||||
-- Folder 1
|
||||
---- Album 3
|
||||
Shared Albums
|
||||
-- Shared Album 1
|
||||
-- Shared Album 2
|
||||
"""
|
||||
|
||||
from typing import List, Union
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _UNKNOWN_PERSON
|
||||
from osxphotos.datetime_formatter import DateTimeFormatter
|
||||
from osxphotos.path_utils import sanitize_dirname
|
||||
from osxphotos.phototemplate import RenderOptions
|
||||
|
||||
|
||||
def place_folder(photo: osxphotos.PhotoInfo) -> str:
|
||||
"""Return places as folder in format Country/State/City/etc."""
|
||||
if not photo.place:
|
||||
return ""
|
||||
|
||||
places = []
|
||||
if photo.place.names.country:
|
||||
places.append(photo.place.names.country[0])
|
||||
|
||||
if photo.place.names.state_province:
|
||||
places.append(photo.place.names.state_province[0])
|
||||
|
||||
if photo.place.names.sub_administrative_area:
|
||||
places.append(photo.place.names.sub_administrative_area[0])
|
||||
|
||||
if photo.place.names.additional_city_info:
|
||||
places.append(photo.place.names.additional_city_info[0])
|
||||
|
||||
if photo.place.names.area_of_interest:
|
||||
places.append(photo.place.names.area_of_interest[0])
|
||||
|
||||
if places:
|
||||
return "Library/Places/" + "/".join(sanitize_dirname(place) for place in places)
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def photos_folders(photo: osxphotos.PhotoInfo, options: osxphotos.phototemplate.RenderOptions, **kwargs) -> Union[List, str]:
|
||||
"""template function for use with --directory to export photos in a folder structure similar to Photos
|
||||
|
||||
Args:
|
||||
photo: osxphotos.PhotoInfo object
|
||||
options: RenderOptions instance
|
||||
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
|
||||
|
||||
Returns: list of directories for each photo
|
||||
|
||||
"""
|
||||
|
||||
rendered_date, _ = photo.render_template("{created.year}/{created.mm}/{created.dd}")
|
||||
date_path = rendered_date[0]
|
||||
|
||||
def add_date_path(path):
|
||||
"""add date path (year/mm/dd)"""
|
||||
return f"{path}/{date_path}"
|
||||
|
||||
# Library
|
||||
|
||||
directories = []
|
||||
if not photo.hidden and not photo.intrash and not photo.shared:
|
||||
# set directories to [Library/Photos/year/mm/dd]
|
||||
# render_template returns a tuple of [rendered value(s)], [unmatched]
|
||||
# here, we can ignore the unmatched value, assigned to _, as we know template will match
|
||||
directories, _ = photo.render_template(
|
||||
"Library/Photos/{created.year}/{created.mm}/{created.dd}"
|
||||
)
|
||||
|
||||
if photo.favorite:
|
||||
directories.append(add_date_path("Library/Favorites"))
|
||||
if photo.hidden:
|
||||
directories.append(add_date_path("Library/Hidden"))
|
||||
if photo.intrash:
|
||||
directories.append(add_date_path("Library/Recently Deleted"))
|
||||
|
||||
directories.extend(
|
||||
[
|
||||
add_date_path(f"Library/People/{person}")
|
||||
for person in photo.persons
|
||||
if person != _UNKNOWN_PERSON
|
||||
]
|
||||
)
|
||||
|
||||
if photo.place:
|
||||
directories.append(add_date_path(place_folder(photo)))
|
||||
|
||||
if photo.import_info:
|
||||
dt = DateTimeFormatter(photo.import_info.creation_date)
|
||||
directories.append(f"Library/Imports/{dt.year}/{dt.mm}/{dt.dd}")
|
||||
|
||||
# Media Types
|
||||
|
||||
if photo.ismovie:
|
||||
directories.append(add_date_path("Media Types/Videos"))
|
||||
if photo.selfie:
|
||||
directories.append(add_date_path("Media Types/Selfies"))
|
||||
if photo.live_photo:
|
||||
directories.append(add_date_path("Media Types/Live Photos"))
|
||||
if photo.portrait:
|
||||
directories.append(add_date_path("Media Types/Portrait"))
|
||||
if photo.panorama:
|
||||
directories.append(add_date_path("Media Types/Panoramas"))
|
||||
if photo.time_lapse:
|
||||
directories.append(add_date_path("Media Types/Time-lapse"))
|
||||
if photo.slow_mo:
|
||||
directories.append(add_date_path("Media Types/Slo-mo"))
|
||||
if photo.burst:
|
||||
directories.append(add_date_path("Media Types/Bursts"))
|
||||
if photo.screenshot:
|
||||
directories.append(add_date_path("Media Types/Screenshots"))
|
||||
|
||||
# Albums
|
||||
|
||||
# render the folders and albums in folder/subfolder/album format
|
||||
# the __NO_ALBUM__ is used as a sentinel to strip out photos not in an album
|
||||
# use RenderOptions.dirname to force the rendered folder_album value to be sanitized as a valid path
|
||||
# use RenderOptions.none_str to specify custom value for any photo that doesn't belong to an album so
|
||||
# those can be filtered out; if not specified, none_str is "_"
|
||||
folder_albums, _ = photo.render_template(
|
||||
"{folder_album}", RenderOptions(dirname=True, none_str="__NO_ALBUM__")
|
||||
)
|
||||
|
||||
root_directory = "Shared Albums/" if photo.shared else "My Albums/"
|
||||
directories.extend(
|
||||
[
|
||||
root_directory + folder_album
|
||||
for folder_album in folder_albums
|
||||
if folder_album != "__NO_ALBUM__"
|
||||
]
|
||||
)
|
||||
|
||||
return directories
|
||||
54
examples/post_function.py
Normal file
54
examples/post_function.py
Normal file
@@ -0,0 +1,54 @@
|
||||
""" Example function for use with osxphotos export --post-function option """
|
||||
|
||||
from osxphotos import PhotoInfo, ExportResults
|
||||
|
||||
|
||||
def post_function(
|
||||
photo: PhotoInfo, results: ExportResults, verbose: callable, **kwargs
|
||||
):
|
||||
"""Call this with osxphotos export /path/to/export --post-function post_function.py::post_function
|
||||
This will get called immediately after the photo has been exported
|
||||
|
||||
Args:
|
||||
photo: PhotoInfo instance for the photo that's just been exported
|
||||
results: ExportResults instance with information about the files associated with the exported photo
|
||||
verbose: A function to print verbose output if --verbose is set; if --verbose is not set, acts as a no-op (nothing gets printed)
|
||||
**kwargs: reserved for future use; recommend you include **kwargs so your function still works if additional arguments are added in future versions
|
||||
|
||||
Notes:
|
||||
Use verbose(str) instead of print if you want your function to conditionally output text depending on --verbose flag
|
||||
Any string printed with verbose that contains "warning" or "error" (case-insensitive) will be printed with the appropriate warning or error color
|
||||
Will not be called if --dry-run flag is enabled
|
||||
Will be called immediately after export and before any --post-command commands are executed
|
||||
"""
|
||||
|
||||
# ExportResults has the following properties
|
||||
# fields with filenames contain the full path to the file
|
||||
# exported: list of all files exported
|
||||
# new: list of all new files exported (--update)
|
||||
# updated: list of all files updated (--update)
|
||||
# skipped: list of all files skipped (--update)
|
||||
# exif_updated: list of all files that were updated with --exiftool
|
||||
# touched: list of all files that had date updated with --touch-file
|
||||
# converted_to_jpeg: list of files converted to jpeg with --convert-to-jpeg
|
||||
# sidecar_json_written: list of all JSON sidecar files written
|
||||
# sidecar_json_skipped: list of all JSON sidecar files skipped (--update)
|
||||
# sidecar_exiftool_written: list of all exiftool sidecar files written
|
||||
# sidecar_exiftool_skipped: list of all exiftool sidecar files skipped (--update)
|
||||
# sidecar_xmp_written: list of all XMP sidecar files written
|
||||
# sidecar_xmp_skipped: list of all XMP sidecar files skipped (--update)
|
||||
# missing: list of all missing files
|
||||
# error: list tuples of (filename, error) for any errors generated during export
|
||||
# exiftool_warning: list of tuples of (filename, warning) for any warnings generated by exiftool with --exiftool
|
||||
# exiftool_error: list of tuples of (filename, error) for any errors generated by exiftool with --exiftool
|
||||
# xattr_written: list of files that had extended attributes written
|
||||
# xattr_skipped: list of files that where extended attributes were skipped (--update)
|
||||
# deleted_files: list of deleted files
|
||||
# deleted_directories: list of deleted directories
|
||||
# exported_album: list of tuples of (filename, album_name) for exported files added to album with --add-exported-to-album
|
||||
# skipped_album: list of tuples of (filename, album_name) for skipped files added to album with --add-skipped-to-album
|
||||
# missing_album: list of tuples of (filename, album_name) for missing files added to album with --add-missing-to-album
|
||||
|
||||
for filename in results.exported:
|
||||
# do your processing here
|
||||
verbose(f"post_function: {photo.original_filename} exported as {filename}")
|
||||
31
examples/query_function.py
Normal file
31
examples/query_function.py
Normal file
@@ -0,0 +1,31 @@
|
||||
""" example function for osxphotos --query-function """
|
||||
|
||||
from typing import List
|
||||
|
||||
from osxphotos import PhotoInfo
|
||||
|
||||
|
||||
# call this with --query-function examples/query_function.py::best_selfies
|
||||
def best_selfies(photos: List[PhotoInfo]) -> List[PhotoInfo]:
|
||||
"""your query function should take a list of PhotoInfo objects and return a list of PhotoInfo objects (or empty list)"""
|
||||
# this example finds your best selfie for every year
|
||||
|
||||
# get list of selfies sorted by date
|
||||
photos = sorted([p for p in photos if p.selfie], key=lambda p: p.date)
|
||||
if not photos:
|
||||
return []
|
||||
|
||||
start_year = photos[0].date.year
|
||||
stop_year = photos[-1].date.year
|
||||
best_selfies = []
|
||||
for year in range(start_year, stop_year + 1):
|
||||
# find best selfie each year as determined by overall aesthetic score
|
||||
selfies = sorted(
|
||||
[p for p in photos if p.date.year == year],
|
||||
key=lambda p: p.score.overall,
|
||||
reverse=True,
|
||||
)
|
||||
if selfies:
|
||||
best_selfies.append(selfies[0])
|
||||
|
||||
return best_selfies
|
||||
17
examples/template_filter.py
Normal file
17
examples/template_filter.py
Normal file
@@ -0,0 +1,17 @@
|
||||
""" Example of using a custom python function as an osxphotos template filter
|
||||
|
||||
Use in formath:
|
||||
"{template_field|template_filter.py::myfilter}"
|
||||
|
||||
Your filter function will receive a list of strings even if the template renders to a single value.
|
||||
You should expect a list and return a list and be able to handle multi-value templates like {keyword}
|
||||
as well as single-value templates like {original_name}
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
def myfilter(values: List[str]) -> List[str]:
|
||||
""" Custom filter to append "foo-" to template value """
|
||||
values = ["foo-" + val for val in values]
|
||||
return values
|
||||
|
||||
30
examples/template_function.py
Normal file
30
examples/template_function.py
Normal file
@@ -0,0 +1,30 @@
|
||||
""" Example showing how to use a custom function for osxphotos {function} template
|
||||
Use: osxphotos export /path/to/export --filename "{function:/path/to/template_function.py::example}"
|
||||
|
||||
You may place more than one template function in a single file as each is called by name using the {function:file.py::function_name} format
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
from typing import List, Union
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
def example(photo: osxphotos.PhotoInfo, **kwargs) -> Union[List, str]:
|
||||
""" example function for {function} template; adds suffix of # if photo has adjustments and ! if photo is a favorite
|
||||
|
||||
Args:
|
||||
photo: osxphotos.PhotoInfo object
|
||||
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
|
||||
|
||||
Returns:
|
||||
str or list of str of values that should be substituted for the {function} template
|
||||
"""
|
||||
|
||||
filename = pathlib.Path(photo.original_filename).stem
|
||||
if photo.hasadjustments:
|
||||
filename += "#"
|
||||
if photo.favorite:
|
||||
filename += "!"
|
||||
|
||||
return filename
|
||||
@@ -8,41 +8,51 @@ import importlib
|
||||
pathex = os.getcwd()
|
||||
|
||||
# include necessary data files
|
||||
datas=[('osxphotos/templates/xmp_sidecar.mako', 'osxphotos/templates'), ('osxphotos/templates/xmp_sidecar_beta.mako', 'osxphotos/templates'), ('osxphotos/phototemplate.tx', 'osxphotos'), ('osxphotos/phototemplate.md', 'osxphotos')]
|
||||
package_imports = [['photoscript', ['photoscript.applescript']]]
|
||||
datas = [
|
||||
("osxphotos/templates/xmp_sidecar.mako", "osxphotos/templates"),
|
||||
("osxphotos/templates/xmp_sidecar_beta.mako", "osxphotos/templates"),
|
||||
("osxphotos/phototemplate.tx", "osxphotos"),
|
||||
("osxphotos/phototemplate.md", "osxphotos"),
|
||||
("osxphotos/tutorial.md", "osxphotos"),
|
||||
("osxphotos/exiftool_filetypes.json", "osxphotos"),
|
||||
]
|
||||
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)
|
||||
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)
|
||||
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 )
|
||||
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,10 +1,44 @@
|
||||
from ._constants import AlbumSortOrder
|
||||
from ._version import __version__
|
||||
from .exiftool import ExifTool
|
||||
from .export_db import ExportDB
|
||||
from .fileutil import FileUtil, FileUtilNoOp
|
||||
from .momentinfo import MomentInfo
|
||||
from .personinfo import PersonInfo
|
||||
from .photoexporter import ExportOptions, ExportResults, PhotoExporter
|
||||
from .photoinfo import PhotoInfo
|
||||
from .photosdb import PhotosDB
|
||||
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
|
||||
from .phototemplate import PhotoTemplate
|
||||
from .placeinfo import PlaceInfo
|
||||
from .queryoptions import QueryOptions
|
||||
from .scoreinfo import ScoreInfo
|
||||
from .searchinfo import SearchInfo
|
||||
from .utils import _debug, _get_logger, _set_debug
|
||||
|
||||
# TODO: Add test for imageTimeZoneOffsetSeconds = None
|
||||
# TODO: Add test for __str__ and to_json
|
||||
# TODO: Add special albums and magic albums
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"_debug",
|
||||
"_get_logger",
|
||||
"_set_debug",
|
||||
"AlbumSortOrder",
|
||||
"CommentInfo",
|
||||
"ExifTool",
|
||||
"ExportDB",
|
||||
"ExportDBTemp",
|
||||
"ExportOptions",
|
||||
"ExportResults",
|
||||
"FileUtil",
|
||||
"FileUtilNoOp",
|
||||
"LikeInfo",
|
||||
"MomentInfo",
|
||||
"PersonInfo",
|
||||
"PhotoExporter",
|
||||
"PhotoInfo",
|
||||
"PhotosDB",
|
||||
"PhotoTemplate",
|
||||
"PlaceInfo",
|
||||
"QueryOptions",
|
||||
"ScoreInfo",
|
||||
"SearchInfo",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Command line interface for osxphotos """
|
||||
|
||||
from .cli import cli
|
||||
from .cli.cli import cli_main
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli() # pylint: disable=no-value-for-parameter
|
||||
cli_main()
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
""" applescript -- Easy-to-use Python wrapper for NSAppleScript """
|
||||
|
||||
import sys
|
||||
|
||||
from Foundation import NSAppleScript, NSAppleEventDescriptor, NSURL, \
|
||||
NSAppleScriptErrorMessage, NSAppleScriptErrorBriefMessage, \
|
||||
NSAppleScriptErrorNumber, NSAppleScriptErrorAppName, NSAppleScriptErrorRange
|
||||
|
||||
from .aecodecs import Codecs, fourcharcode, AEType, AEEnum
|
||||
from . import kae
|
||||
|
||||
__all__ = ['AppleScript', 'ScriptError', 'AEType', 'AEEnum', 'kMissingValue', 'kae']
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
class AppleScript:
|
||||
""" Represents a compiled AppleScript. The script object is persistent; its handlers may be called multiple times and its top-level properties will retain current state until the script object's disposal.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
_codecs = Codecs()
|
||||
|
||||
def __init__(self, source=None, path=None):
|
||||
"""
|
||||
source : str | None -- AppleScript source code
|
||||
path : str | None -- full path to .scpt/.applescript file
|
||||
|
||||
Notes:
|
||||
|
||||
- Either the path or the source argument must be provided.
|
||||
|
||||
- If the script cannot be read/compiled, a ScriptError is raised.
|
||||
"""
|
||||
if path:
|
||||
url = NSURL.fileURLWithPath_(path)
|
||||
self._script, errorinfo = NSAppleScript.alloc().initWithContentsOfURL_error_(url, None)
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
elif source:
|
||||
self._script = NSAppleScript.alloc().initWithSource_(source)
|
||||
else:
|
||||
raise ValueError("Missing source or path argument.")
|
||||
if not self._script.isCompiled():
|
||||
errorinfo = self._script.compileAndReturnError_(None)[1]
|
||||
if errorinfo:
|
||||
raise ScriptError(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
s = self.source
|
||||
return 'AppleScript({})'.format(repr(s) if len(s) < 100 else '{}...{}'.format(repr(s)[:80], repr(s)[-17:]))
|
||||
|
||||
##
|
||||
|
||||
def _newevent(self, suite, code, args):
|
||||
evt = NSAppleEventDescriptor.appleEventWithEventClass_eventID_targetDescriptor_returnID_transactionID_(
|
||||
fourcharcode(suite), fourcharcode(code), NSAppleEventDescriptor.nullDescriptor(), 0, 0)
|
||||
evt.setDescriptor_forKeyword_(self._codecs.pack(args), fourcharcode(kae.keyDirectObject))
|
||||
return evt
|
||||
|
||||
def _unpackresult(self, result, errorinfo):
|
||||
if not result:
|
||||
raise ScriptError(errorinfo)
|
||||
return self._codecs.unpack(result)
|
||||
|
||||
##
|
||||
|
||||
source = property(lambda self: str(self._script.source()), doc="str -- the script's source code")
|
||||
|
||||
def run(self, *args):
|
||||
""" Run the script, optionally passing arguments to its run handler.
|
||||
|
||||
args : anything -- arguments to pass to script, if any; see also supported type mappings documentation
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The run handler must be explicitly declared in order to pass arguments.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
if args:
|
||||
evt = self._newevent(kae.kCoreEventClass, kae.kAEOpenApplication, args)
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
else:
|
||||
return self._unpackresult(*self._script.executeAndReturnError_(None))
|
||||
|
||||
def call(self, name, *args):
|
||||
""" Call the specified user-defined handler.
|
||||
|
||||
name : str -- the handler's name (case-sensitive)
|
||||
args : anything -- arguments to pass to script, if any; see documentation for supported types
|
||||
Result : anything | None -- the script's return value, if any
|
||||
|
||||
Notes:
|
||||
|
||||
- The handler's name must be a user-defined identifier, not an AppleScript keyword; e.g. 'myCount' is acceptable; 'count' is not.
|
||||
|
||||
- AppleScript will ignore excess arguments. Passing insufficient arguments will result in an error.
|
||||
|
||||
- If execution fails, a ScriptError is raised.
|
||||
"""
|
||||
evt = self._newevent(kae.kASAppleScriptSuite, kae.kASPrepositionalSubroutine, args)
|
||||
evt.setDescriptor_forKeyword_(NSAppleEventDescriptor.descriptorWithString_(name),
|
||||
fourcharcode(kae.keyASSubroutineName))
|
||||
return self._unpackresult(*self._script.executeAppleEvent_error_(evt, None))
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class ScriptError(Exception):
|
||||
""" Indicates an AppleScript compilation/execution error. """
|
||||
|
||||
def __init__(self, errorinfo):
|
||||
self._errorinfo = dict(errorinfo)
|
||||
|
||||
def __repr__(self):
|
||||
return 'ScriptError({})'.format(self._errorinfo)
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
""" str -- the error message """
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorMessage)
|
||||
if not msg:
|
||||
msg = self._errorinfo.get(NSAppleScriptErrorBriefMessage, 'Script Error')
|
||||
return msg
|
||||
|
||||
number = property(lambda self: self._errorinfo.get(NSAppleScriptErrorNumber),
|
||||
doc="int | None -- the error number, if given")
|
||||
|
||||
appname = property(lambda self: self._errorinfo.get(NSAppleScriptErrorAppName),
|
||||
doc="str | None -- the name of the application that reported the error, where relevant")
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
""" (int, int) -- the start and end points (1-indexed) within the source code where the error occurred """
|
||||
range = self._errorinfo.get(NSAppleScriptErrorRange)
|
||||
if range:
|
||||
start = range.rangeValue().location
|
||||
end = start + range.rangeValue().length
|
||||
return (start, end)
|
||||
else:
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
msg = self.message
|
||||
for s, v in [(' ({})', self.number), (' app={!r}', self.appname), (' range={0[0]}-{0[1]}', self.range)]:
|
||||
if v is not None:
|
||||
msg += s.format(v)
|
||||
return msg.encode('ascii', 'replace') if sys.version_info.major < 3 else msg # 2.7 compatibility
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
kMissingValue = AEType(kae.cMissingValue) # convenience constant
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
""" aecodecs -- Convert from common Python types to Apple Event Manager types and vice-versa. """
|
||||
|
||||
import datetime, struct, sys
|
||||
|
||||
from Foundation import NSAppleEventDescriptor, NSURL
|
||||
|
||||
from . import kae
|
||||
|
||||
|
||||
__all__ = ['Codecs', 'AEType', 'AEEnum']
|
||||
|
||||
|
||||
######################################################################
|
||||
|
||||
|
||||
def fourcharcode(code):
|
||||
""" Convert four-char code for use in NSAppleEventDescriptor methods.
|
||||
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
Result : int -- OSType, e.g. 1970567284
|
||||
"""
|
||||
return struct.unpack('>I', code)[0]
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class Codecs:
|
||||
""" Implements mappings for common Python types with direct AppleScript equivalents. Used by AppleScript class. """
|
||||
|
||||
kMacEpoch = datetime.datetime(1904, 1, 1)
|
||||
kUSRF = fourcharcode(kae.keyASUserRecordFields)
|
||||
|
||||
def __init__(self):
|
||||
# Clients may add/remove/replace encoder and decoder items:
|
||||
self.encoders = {
|
||||
NSAppleEventDescriptor.class__(): self.packdesc,
|
||||
type(None): self.packnone,
|
||||
bool: self.packbool,
|
||||
int: self.packint,
|
||||
float: self.packfloat,
|
||||
bytes: self.packbytes,
|
||||
str: self.packstr,
|
||||
list: self.packlist,
|
||||
tuple: self.packlist,
|
||||
dict: self.packdict,
|
||||
datetime.datetime: self.packdatetime,
|
||||
AEType: self.packtype,
|
||||
AEEnum: self.packenum,
|
||||
}
|
||||
if sys.version_info.major < 3: # 2.7 compatibility
|
||||
self.encoders[unicode] = self.packstr
|
||||
|
||||
self.decoders = {fourcharcode(k): v for k, v in {
|
||||
kae.typeNull: self.unpacknull,
|
||||
kae.typeBoolean: self.unpackboolean,
|
||||
kae.typeFalse: self.unpackboolean,
|
||||
kae.typeTrue: self.unpackboolean,
|
||||
kae.typeSInt32: self.unpacksint32,
|
||||
kae.typeIEEE64BitFloatingPoint: self.unpackfloat64,
|
||||
kae.typeUTF8Text: self.unpackunicodetext,
|
||||
kae.typeUTF16ExternalRepresentation: self.unpackunicodetext,
|
||||
kae.typeUnicodeText: self.unpackunicodetext,
|
||||
kae.typeLongDateTime: self.unpacklongdatetime,
|
||||
kae.typeAEList: self.unpackaelist,
|
||||
kae.typeAERecord: self.unpackaerecord,
|
||||
kae.typeAlias: self.unpackfile,
|
||||
kae.typeFSS: self.unpackfile,
|
||||
kae.typeFSRef: self.unpackfile,
|
||||
kae.typeFileURL: self.unpackfile,
|
||||
kae.typeType: self.unpacktype,
|
||||
kae.typeEnumeration: self.unpackenumeration,
|
||||
}.items()}
|
||||
|
||||
def pack(self, data):
|
||||
"""Pack Python data.
|
||||
data : anything -- a Python value
|
||||
Result : NSAppleEventDescriptor -- an AE descriptor, or error if no encoder exists for this type of data
|
||||
"""
|
||||
try:
|
||||
return self.encoders[data.__class__](data) # quick lookup by type/class
|
||||
except (KeyError, AttributeError) as e:
|
||||
for type, encoder in self.encoders.items(): # slower but more thorough lookup that can handle subtypes/subclasses
|
||||
if isinstance(data, type):
|
||||
return encoder(data)
|
||||
raise TypeError("Can't pack data into an AEDesc (unsupported type): {!r}".format(data))
|
||||
|
||||
def unpack(self, desc):
|
||||
"""Unpack an Apple event descriptor.
|
||||
desc : NSAppleEventDescriptor
|
||||
Result : anything -- a Python value, or the original NSAppleEventDescriptor if no decoder is found
|
||||
"""
|
||||
decoder = self.decoders.get(desc.descriptorType())
|
||||
# unpack known type
|
||||
if decoder:
|
||||
return decoder(desc)
|
||||
# if it's a record-like desc, unpack as dict with an extra AEType(b'pcls') key containing the desc type
|
||||
rec = desc.coerceToDescriptorType_(fourcharcode(kae.typeAERecord))
|
||||
if rec:
|
||||
rec = self.unpackaerecord(rec)
|
||||
rec[AEType(kae.pClass)] = AEType(struct.pack('>I', desc.descriptorType()))
|
||||
return rec
|
||||
# return as-is
|
||||
return desc
|
||||
|
||||
##
|
||||
|
||||
def _packbytes(self, desctype, data):
|
||||
return NSAppleEventDescriptor.descriptorWithDescriptorType_bytes_length_(
|
||||
fourcharcode(desctype), data, len(data))
|
||||
|
||||
def packdesc(self, val):
|
||||
return val
|
||||
|
||||
def packnone(self, val):
|
||||
return NSAppleEventDescriptor.nullDescriptor()
|
||||
|
||||
def packbool(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithBoolean_(int(val))
|
||||
|
||||
def packint(self, val):
|
||||
if (-2**31) <= val < (2**31):
|
||||
return NSAppleEventDescriptor.descriptorWithInt32_(val)
|
||||
else:
|
||||
return self.pack(float(val))
|
||||
|
||||
def packfloat(self, val):
|
||||
return self._packbytes(kae.typeFloat, struct.pack('d', val))
|
||||
|
||||
def packbytes(self, val):
|
||||
return self._packbytes(kae.typeData, val)
|
||||
|
||||
def packstr(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithString_(val)
|
||||
|
||||
def packdatetime(self, val):
|
||||
delta = val - self.kMacEpoch
|
||||
sec = delta.days * 3600 * 24 + delta.seconds
|
||||
return self._packbytes(kae.typeLongDateTime, struct.pack('q', sec))
|
||||
|
||||
def packlist(self, val):
|
||||
lst = NSAppleEventDescriptor.listDescriptor()
|
||||
for item in val:
|
||||
lst.insertDescriptor_atIndex_(self.pack(item), 0)
|
||||
return lst
|
||||
|
||||
def packdict(self, val):
|
||||
record = NSAppleEventDescriptor.recordDescriptor()
|
||||
usrf = desctype = None
|
||||
for key, value in val.items():
|
||||
if isinstance(key, AEType):
|
||||
if key.code == kae.pClass and isinstance(value, AEType): # AS packs records that contain a 'class' property by coercing the packed record to the descriptor type specified by the property's value (assuming it's an AEType)
|
||||
desctype = value
|
||||
else:
|
||||
record.setDescriptor_forKeyword_(self.pack(value), fourcharcode(key.code))
|
||||
else:
|
||||
if not usrf:
|
||||
usrf = NSAppleEventDescriptor.listDescriptor()
|
||||
usrf.insertDescriptor_atIndex_(self.pack(key), 0)
|
||||
usrf.insertDescriptor_atIndex_(self.pack(value), 0)
|
||||
if usrf:
|
||||
record.setDescriptor_forKeyword_(usrf, self.kUSRF)
|
||||
if desctype:
|
||||
newrecord = record.coerceToDescriptorType_(fourcharcode(desctype.code))
|
||||
if newrecord:
|
||||
record = newrecord
|
||||
else: # coercion failed for some reason, so pack as normal key-value pair
|
||||
record.setDescriptor_forKeyword_(self.pack(desctype), fourcharcode(key.code))
|
||||
return record
|
||||
|
||||
def packtype(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithTypeCode_(fourcharcode(val.code))
|
||||
|
||||
def packenum(self, val):
|
||||
return NSAppleEventDescriptor.descriptorWithEnumCode_(fourcharcode(val.code))
|
||||
|
||||
#######
|
||||
|
||||
def unpacknull(self, desc):
|
||||
return None
|
||||
|
||||
def unpackboolean(self, desc):
|
||||
return desc.booleanValue()
|
||||
|
||||
def unpacksint32(self, desc):
|
||||
return desc.int32Value()
|
||||
|
||||
def unpackfloat64(self, desc):
|
||||
return struct.unpack('d', bytes(desc.data()))[0]
|
||||
|
||||
def unpackunicodetext(self, desc):
|
||||
return desc.stringValue()
|
||||
|
||||
def unpacklongdatetime(self, desc):
|
||||
return self.kMacEpoch + datetime.timedelta(seconds=struct.unpack('q', bytes(desc.data()))[0])
|
||||
|
||||
def unpackaelist(self, desc):
|
||||
return [self.unpack(desc.descriptorAtIndex_(i + 1)) for i in range(desc.numberOfItems())]
|
||||
|
||||
def unpackaerecord(self, desc):
|
||||
dct = {}
|
||||
for i in range(desc.numberOfItems()):
|
||||
key = desc.keywordForDescriptorAtIndex_(i + 1)
|
||||
value = desc.descriptorForKeyword_(key)
|
||||
if key == self.kUSRF:
|
||||
lst = self.unpackaelist(value)
|
||||
for i in range(0, len(lst), 2):
|
||||
dct[lst[i]] = lst[i+1]
|
||||
else:
|
||||
dct[AEType(struct.pack('>I', key))] = self.unpack(value)
|
||||
return dct
|
||||
|
||||
def unpacktype(self, desc):
|
||||
return AEType(struct.pack('>I', desc.typeCodeValue()))
|
||||
|
||||
def unpackenumeration(self, desc):
|
||||
return AEEnum(struct.pack('>I', desc.enumCodeValue()))
|
||||
|
||||
def unpackfile(self, desc):
|
||||
url = bytes(desc.coerceToDescriptorType_(fourcharcode(kae.typeFileURL)).data()).decode('utf8')
|
||||
return NSURL.URLWithString_(url).path()
|
||||
|
||||
|
||||
#######
|
||||
|
||||
|
||||
class AETypeBase:
|
||||
""" Base class for AEType and AEEnum.
|
||||
|
||||
Notes:
|
||||
|
||||
- Hashable and comparable, so may be used as keys in dictionaries that map to AE records.
|
||||
"""
|
||||
|
||||
def __init__(self, code):
|
||||
"""
|
||||
code : bytes -- four-char code, e.g. b'utxt'
|
||||
"""
|
||||
if not isinstance(code, bytes):
|
||||
raise TypeError('invalid code (not a bytes object): {!r}'.format(code))
|
||||
elif len(code) != 4:
|
||||
raise ValueError('invalid code (not four bytes long): {!r}'.format(code))
|
||||
self._code = code
|
||||
|
||||
code = property(lambda self:self._code, doc="bytes -- four-char code, e.g. b'utxt'")
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self._code)
|
||||
|
||||
def __eq__(self, val):
|
||||
return val.__class__ == self.__class__ and val.code == self._code
|
||||
|
||||
def __ne__(self, val):
|
||||
return not self == val
|
||||
|
||||
def __repr__(self):
|
||||
return "{}({!r})".format(self.__class__.__name__, self._code)
|
||||
|
||||
|
||||
##
|
||||
|
||||
|
||||
class AEType(AETypeBase):
|
||||
"""An AE type. Maps to an AppleScript type class, e.g. AEType(b'utxt') <=> 'unicode text'."""
|
||||
|
||||
|
||||
class AEEnum(AETypeBase):
|
||||
"""An AE enumeration. Maps to an AppleScript constant, e.g. AEEnum(b'yes ') <=> 'yes'."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ Constants used by osxphotos
|
||||
|
||||
import os.path
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
OSXPHOTOS_URL = "https://github.com/RhetTbull/osxphotos"
|
||||
|
||||
@@ -19,8 +20,8 @@ UNICODE_FORMAT = "NFC"
|
||||
# Photos 3.0 (10.13.6) == 3301
|
||||
# Photos 4.0 (10.14.5) == 4016
|
||||
# Photos 4.0 (10.14.6) == 4025
|
||||
# Photos 5.0 (10.15.0) == 6000
|
||||
_TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
|
||||
# Photos 5.0 (10.15.0) == 6000 or 5001
|
||||
_TESTED_DB_VERSIONS = ["6000", "5001", "4025", "4016", "3301", "2622"]
|
||||
|
||||
# database model versions (applies to Photos 5, Photos 6)
|
||||
# these come from PLModelVersion key in binary plist in Z_METADATA.Z_PLIST
|
||||
@@ -29,16 +30,22 @@ _TESTED_DB_VERSIONS = ["6000", "4025", "4016", "3301", "2622"]
|
||||
# Photos 6 (10.16.0 Beta) == 14104
|
||||
_TEST_MODEL_VERSIONS = ["13537", "13703", "14104"]
|
||||
|
||||
_PHOTOS_2_VERSION = "2622"
|
||||
|
||||
# only version 3 - 4 have RKVersion.selfPortrait
|
||||
_PHOTOS_3_VERSION = "3301"
|
||||
|
||||
# versions 5.0 and later have a different database structure
|
||||
_PHOTOS_4_VERSION = "4025" # latest Mojove version on 10.14.6
|
||||
_PHOTOS_5_VERSION = "6000" # seems to be current on 10.15.1 through 10.15.6
|
||||
_PHOTOS_5_VERSION = "5000" # I've seen both 5001 and 6000. 6000 is most common on Catalina and up but there are some version 5001 database in the wild
|
||||
|
||||
# Ranges for model version by Photos version
|
||||
_PHOTOS_5_MODEL_VERSION = [13000, 13999]
|
||||
_PHOTOS_6_MODEL_VERSION = [14000, 14999]
|
||||
_PHOTOS_7_MODEL_VERSION = [
|
||||
15000,
|
||||
15999,
|
||||
] # Monterey developer preview is 15134, 12.1 is 15331
|
||||
|
||||
# some table names differ between Photos 5 and Photos 6
|
||||
_DB_TABLE_NAMES = {
|
||||
@@ -49,6 +56,10 @@ _DB_TABLE_NAMES = {
|
||||
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_34ASSETS",
|
||||
"IMPORT_FOK": "ZGENERICASSET.Z_FOK_IMPORTSESSION",
|
||||
"DEPTH_STATE": "ZGENERICASSET.ZDEPTHSTATES",
|
||||
"UTI_ORIGINAL": "ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER",
|
||||
"ASSET_ALBUM_JOIN": "Z_26ASSETS.Z_26ALBUMS",
|
||||
"ASSET_ALBUM_TABLE": "Z_26ASSETS",
|
||||
"HDR_TYPE": "ZCUSTOMRENDEREDVALUE",
|
||||
},
|
||||
6: {
|
||||
"ASSET": "ZASSET",
|
||||
@@ -57,6 +68,22 @@ _DB_TABLE_NAMES = {
|
||||
"ALBUM_SORT_ORDER": "Z_26ASSETS.Z_FOK_3ASSETS",
|
||||
"IMPORT_FOK": "null",
|
||||
"DEPTH_STATE": "ZASSET.ZDEPTHTYPE",
|
||||
"UTI_ORIGINAL": "ZINTERNALRESOURCE.ZUNIFORMTYPEIDENTIFIER",
|
||||
"ASSET_ALBUM_JOIN": "Z_26ASSETS.Z_26ALBUMS",
|
||||
"ASSET_ALBUM_TABLE": "Z_26ASSETS",
|
||||
"HDR_TYPE": "ZCUSTOMRENDEREDVALUE",
|
||||
},
|
||||
7: {
|
||||
"ASSET": "ZASSET",
|
||||
"KEYWORD_JOIN": "Z_1KEYWORDS.Z_38KEYWORDS",
|
||||
"ALBUM_JOIN": "Z_27ASSETS.Z_3ASSETS",
|
||||
"ALBUM_SORT_ORDER": "Z_27ASSETS.Z_FOK_3ASSETS",
|
||||
"IMPORT_FOK": "null",
|
||||
"DEPTH_STATE": "ZASSET.ZDEPTHTYPE",
|
||||
"UTI_ORIGINAL": "ZINTERNALRESOURCE.ZCOMPACTUTI",
|
||||
"ASSET_ALBUM_JOIN": "Z_27ASSETS.Z_27ALBUMS",
|
||||
"ASSET_ALBUM_TABLE": "Z_27ASSETS",
|
||||
"HDR_TYPE": "ZHDRTYPE",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -70,6 +97,14 @@ _TESTED_OS_VERSIONS = [
|
||||
("11", "0"),
|
||||
("11", "1"),
|
||||
("11", "2"),
|
||||
("11", "3"),
|
||||
("11", "4"),
|
||||
("11", "5"),
|
||||
("11", "6"),
|
||||
("12", "0"),
|
||||
("12", "1"),
|
||||
("12", "2"),
|
||||
("12", "3"),
|
||||
]
|
||||
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
@@ -95,12 +130,20 @@ _XMP_TEMPLATE_NAME_BETA = "xmp_sidecar_beta.mako"
|
||||
# Constants used for processing folders and albums
|
||||
_PHOTOS_5_ALBUM_KIND = 2 # normal user album
|
||||
_PHOTOS_5_SHARED_ALBUM_KIND = 1505 # shared album
|
||||
_PHOTOS_5_PROJECT_ALBUM_KIND = 1508 # My Projects (e.g. Calendar, Card, Slideshow)
|
||||
_PHOTOS_5_FOLDER_KIND = 4000 # user folder
|
||||
_PHOTOS_5_ROOT_FOLDER_KIND = 3999 # root folder
|
||||
_PHOTOS_5_IMPORT_SESSION_ALBUM_KIND = 1506 # import session
|
||||
|
||||
_PHOTOS_4_ALBUM_KIND = 3 # RKAlbum.albumSubclass
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUM = "TopLevelAlbums"
|
||||
_PHOTOS_4_ALBUM_TYPE_ALBUM = 1 # RKAlbum.albumType
|
||||
_PHOTOS_4_ALBUM_TYPE_PROJECT = 9 # RKAlbum.albumType
|
||||
_PHOTOS_4_ALBUM_TYPE_SLIDESHOW = 8 # RKAlbum.albumType
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUMS = [
|
||||
"TopLevelAlbums",
|
||||
"TopLevelKeepsakes",
|
||||
"TopLevelSlideshows",
|
||||
]
|
||||
_PHOTOS_4_ROOT_FOLDER = "LibraryFolder"
|
||||
|
||||
# EXIF related constants
|
||||
@@ -187,9 +230,8 @@ 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"
|
||||
# Default suffix to add to preview images
|
||||
DEFAULT_PREVIEW_SUFFIX = "_preview"
|
||||
|
||||
# Bit masks for --sidecar
|
||||
SIDECAR_JSON = 0x1
|
||||
@@ -201,12 +243,82 @@ EXTENDED_ATTRIBUTE_NAMES = [
|
||||
"authors",
|
||||
"comment",
|
||||
"copyright",
|
||||
"creator",
|
||||
"description",
|
||||
"findercomment",
|
||||
"headline",
|
||||
"keywords",
|
||||
"participants",
|
||||
"projects",
|
||||
"rating",
|
||||
"subject",
|
||||
"title",
|
||||
"version",
|
||||
]
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
|
||||
|
||||
|
||||
# name of export DB
|
||||
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
|
||||
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
|
||||
|
||||
# bit flags for burst images ("burstPickType")
|
||||
BURST_PICK_TYPE_NONE = 0b0 # 0: sometimes used for single images with a burst UUID
|
||||
BURST_NOT_SELECTED = 0b10 # 2: burst image is not selected
|
||||
BURST_DEFAULT_PICK = 0b100 # 4: burst image is the one Photos picked to be key image before any selections made
|
||||
BURST_SELECTED = 0b1000 # 8: burst image is selected
|
||||
BURST_KEY = 0b10000 # 16: burst image is the key photo (top of burst stack)
|
||||
BURST_UNKNOWN = 0b100000 # 32: this is almost always set with BURST_DEFAULT_PICK and never if BURST_DEFAULT_PICK is not set. I think this has something to do with what algorithm Photos used to pick the default image
|
||||
|
||||
LIVE_VIDEO_EXTENSIONS = [".mov"]
|
||||
|
||||
# categories that --post-command can be used with; these map to ExportResults fields
|
||||
POST_COMMAND_CATEGORIES = {
|
||||
"exported": "All exported files",
|
||||
"new": "When used with '--update', all newly exported files",
|
||||
"updated": "When used with '--update', all files which were previously exported but updated this time",
|
||||
"skipped": "When used with '--update', all files which were skipped (because they were previously exported and didn't change)",
|
||||
"missing": "All files which were not exported because they were missing from the Photos library",
|
||||
"exif_updated": "When used with '--exiftool', all files on which exiftool updated the metadata",
|
||||
"touched": "When used with '--touch-file', all files where the date was touched",
|
||||
"converted_to_jpeg": "When used with '--convert-to-jpeg', all files which were converted to jpeg",
|
||||
"sidecar_json_written": "When used with '--sidecar json', all JSON sidecar files which were written",
|
||||
"sidecar_json_skipped": "When used with '--sidecar json' and '--update', all JSON sidecar files which were skipped",
|
||||
"sidecar_exiftool_written": "When used with '--sidecar exiftool', all exiftool sidecar files which were written",
|
||||
"sidecar_exiftool_skipped": "When used with '--sidecar exiftool' and '--update, all exiftool sidecar files which were skipped",
|
||||
"sidecar_xmp_written": "When used with '--sidecar xmp', all XMP sidecar files which were written",
|
||||
"sidecar_xmp_skipped": "When used with '--sidecar xmp' and '--update', all XMP sidecar files which were skipped",
|
||||
"error": "All files which produced an error during export",
|
||||
# "deleted_files": "When used with '--cleanup', all files deleted during the export",
|
||||
# "deleted_directories": "When used with '--cleanup', all directories deleted during the export",
|
||||
}
|
||||
|
||||
|
||||
class AlbumSortOrder(Enum):
|
||||
"""Album Sort Order"""
|
||||
|
||||
UNKNOWN = 0
|
||||
MANUAL = 1
|
||||
NEWEST_FIRST = 2
|
||||
OLDEST_FIRST = 3
|
||||
TITLE = 5
|
||||
|
||||
|
||||
TEXT_DETECTION_CONFIDENCE_THRESHOLD = 0.75
|
||||
|
||||
# stat sort order for cProfile: https://docs.python.org/3/library/profile.html#pstats.Stats.sort_stats
|
||||
PROFILE_SORT_KEYS = [
|
||||
"calls",
|
||||
"cumulative",
|
||||
"cumtime",
|
||||
"file",
|
||||
"filename",
|
||||
"module",
|
||||
"ncalls",
|
||||
"pcalls",
|
||||
"line",
|
||||
"name",
|
||||
"nfl",
|
||||
"stdname",
|
||||
"time",
|
||||
"tottime",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.41.3"
|
||||
__version__ = "0.47.2"
|
||||
|
||||
@@ -16,6 +16,8 @@ import zlib
|
||||
|
||||
from .datetime_utils import datetime_naive_to_utc
|
||||
|
||||
__all__ = ["AdjustmentsDecodeError", "AdjustmentsInfo"]
|
||||
|
||||
|
||||
class AdjustmentsDecodeError(Exception):
|
||||
"""Could not decode adjustments plist file"""
|
||||
@@ -73,37 +75,37 @@ class AdjustmentsInfo:
|
||||
|
||||
@property
|
||||
def plist(self):
|
||||
"""The actual adjustments plist content as a dict """
|
||||
"""The actual adjustments plist content as a dict"""
|
||||
return self._plist
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
"""The raw adjustments data as a binary blob """
|
||||
"""The raw adjustments data as a binary blob"""
|
||||
return self._data
|
||||
|
||||
@property
|
||||
def editor(self):
|
||||
"""The editor bundle ID for app/plug-in which made the adjustments """
|
||||
"""The editor bundle ID for app/plug-in which made the adjustments"""
|
||||
return self._editor_bundle_id
|
||||
|
||||
@property
|
||||
def format_id(self):
|
||||
"""The value of the adjustmentFormatIdentifier field in the plist """
|
||||
"""The value of the adjustmentFormatIdentifier field in the plist"""
|
||||
return self._format_identifier
|
||||
|
||||
@property
|
||||
def base_version(self):
|
||||
"""Value of adjustmentBaseVersion field """
|
||||
"""Value of adjustmentBaseVersion field"""
|
||||
return self._base_version
|
||||
|
||||
@property
|
||||
def format_version(self):
|
||||
"""The value of the adjustmentFormatVersion in the plist """
|
||||
"""The value of the adjustmentFormatVersion in the plist"""
|
||||
return self._format_version
|
||||
|
||||
@property
|
||||
def timestamp(self):
|
||||
"""The time stamp of the adjustment as timezone aware datetime.datetime object or None if no timestamp """
|
||||
"""The time stamp of the adjustment as timezone aware datetime.datetime object or None if no timestamp"""
|
||||
return self._timestamp
|
||||
|
||||
@property
|
||||
|
||||
@@ -14,26 +14,37 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ._constants import (
|
||||
_PHOTOS_4_ALBUM_KIND,
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUM,
|
||||
_PHOTOS_4_TOP_LEVEL_ALBUMS,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_ALBUM_KIND,
|
||||
_PHOTOS_5_FOLDER_KIND,
|
||||
TIME_DELTA,
|
||||
AlbumSortOrder,
|
||||
)
|
||||
from .datetime_utils import get_local_tz
|
||||
from .query_builder import get_query
|
||||
|
||||
__all__ = [
|
||||
"sort_list_by_keys",
|
||||
"AlbumInfoBaseClass",
|
||||
"AlbumInfo",
|
||||
"ImportInfo",
|
||||
"ProjectInfo",
|
||||
"FolderInfo",
|
||||
]
|
||||
|
||||
|
||||
def sort_list_by_keys(values, sort_keys):
|
||||
""" Sorts list values by a second list sort_keys
|
||||
"""Sorts list values by a second list sort_keys
|
||||
e.g. given ["a","c","b"], [1, 3, 2], returns ["a", "b", "c"]
|
||||
|
||||
Args:
|
||||
values: a list of values to be sorted
|
||||
sort_keys: a list of keys to sort values by
|
||||
|
||||
|
||||
Returns:
|
||||
list of values, sorted by sort_keys
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: raised if len(values) != len(sort_keys)
|
||||
"""
|
||||
@@ -63,12 +74,12 @@ class AlbumInfoBaseClass:
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" return uuid of album """
|
||||
"""return uuid of album"""
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def creation_date(self):
|
||||
""" return creation date of album """
|
||||
"""return creation date of album"""
|
||||
try:
|
||||
return self._creation_date
|
||||
except AttributeError:
|
||||
@@ -90,8 +101,8 @@ class AlbumInfoBaseClass:
|
||||
|
||||
@property
|
||||
def start_date(self):
|
||||
""" For Albums, return start date (earliest image) of album or None for albums with no images
|
||||
For Import Sessions, return start date of import session (when import began) """
|
||||
"""For Albums, return start date (earliest image) of album or None for albums with no images
|
||||
For Import Sessions, return start date of import session (when import began)"""
|
||||
try:
|
||||
return self._start_date
|
||||
except AttributeError:
|
||||
@@ -109,8 +120,8 @@ class AlbumInfoBaseClass:
|
||||
|
||||
@property
|
||||
def end_date(self):
|
||||
""" For Albums, return end date (most recent image) of album or None for albums with no images
|
||||
For Import Sessions, return end date of import sessions (when import was completed) """
|
||||
"""For Albums, return end date (most recent image) of album or None for albums with no images
|
||||
For Import Sessions, return end date of import sessions (when import was completed)"""
|
||||
try:
|
||||
return self._end_date
|
||||
except AttributeError:
|
||||
@@ -130,43 +141,74 @@ class AlbumInfoBaseClass:
|
||||
def photos(self):
|
||||
return []
|
||||
|
||||
@property
|
||||
def owner(self):
|
||||
"""Return name of photo owner for shared album (Photos 5+ only), or None if not shared"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._owner
|
||||
except AttributeError:
|
||||
try:
|
||||
personid = self._db._dbalbum_details[self.uuid][
|
||||
"cloudownerhashedpersonid"
|
||||
]
|
||||
self._owner = (
|
||||
self._db._db_hashed_person_id[personid]["full_name"]
|
||||
if personid
|
||||
else None
|
||||
)
|
||||
except KeyError:
|
||||
self._owner = None
|
||||
return self._owner
|
||||
|
||||
def __len__(self):
|
||||
""" return number of photos contained in album """
|
||||
"""return number of photos contained in album"""
|
||||
return len(self.photos)
|
||||
|
||||
|
||||
class AlbumInfo(AlbumInfoBaseClass):
|
||||
"""
|
||||
Base class for AlbumInfo, ImportInfo
|
||||
Info about a specific Album, contains all the details about the album
|
||||
including folders, photos, etc.
|
||||
"""
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of album """
|
||||
"""return title / name of album"""
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in album sorted in same sort order as Photos """
|
||||
"""return list of photos contained in album sorted in same sort order as Photos"""
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
if self.uuid in self._db._dbalbums_album:
|
||||
uuid, sort_order = zip(*self._db._dbalbums_album[self.uuid])
|
||||
sorted_uuid = sort_list_by_keys(uuid, sort_order)
|
||||
self._photos = self._db.photos_by_uuid(sorted_uuid)
|
||||
photos = self._db.photos_by_uuid(sorted_uuid)
|
||||
sort_order = self.sort_order
|
||||
if sort_order == AlbumSortOrder.NEWEST_FIRST:
|
||||
self._photos = sorted(photos, key=lambda p: p.date, reverse=True)
|
||||
elif sort_order == AlbumSortOrder.OLDEST_FIRST:
|
||||
self._photos = sorted(photos, key=lambda p: p.date)
|
||||
elif sort_order == AlbumSortOrder.TITLE:
|
||||
self._photos = sorted(photos, key=lambda p: p.title or "")
|
||||
else:
|
||||
# assume AlbumSortOrder.MANUAL
|
||||
self._photos = photos
|
||||
else:
|
||||
self._photos = []
|
||||
return self._photos
|
||||
|
||||
@property
|
||||
def folder_names(self):
|
||||
""" return hierarchical list of folders the album is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders """
|
||||
"""return hierarchical list of folders the album is contained in
|
||||
the folder list is in form:
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders"""
|
||||
|
||||
try:
|
||||
return self._folder_names
|
||||
@@ -176,10 +218,10 @@ class AlbumInfo(AlbumInfoBaseClass):
|
||||
|
||||
@property
|
||||
def folder_list(self):
|
||||
""" return hierarchical list of folders the album is contained in
|
||||
as list of FolderInfo objects in form
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders """
|
||||
"""return hierarchical list of folders the album is contained in
|
||||
as list of FolderInfo objects in form
|
||||
["Top level folder", "sub folder 1", "sub folder 2", ...]
|
||||
returns empty list if album is not in any folders"""
|
||||
|
||||
try:
|
||||
return self._folders
|
||||
@@ -189,7 +231,7 @@ class AlbumInfo(AlbumInfoBaseClass):
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
""" returns FolderInfo object for parent folder or None if no parent (e.g. top-level album) """
|
||||
"""returns FolderInfo object for parent folder or None if no parent (e.g. top-level album)"""
|
||||
try:
|
||||
return self._parent
|
||||
except AttributeError:
|
||||
@@ -197,7 +239,7 @@ class AlbumInfo(AlbumInfoBaseClass):
|
||||
parent_uuid = self._db._dbalbum_details[self._uuid]["folderUuid"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=parent_uuid)
|
||||
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
|
||||
if parent_uuid not in _PHOTOS_4_TOP_LEVEL_ALBUMS
|
||||
else None
|
||||
)
|
||||
else:
|
||||
@@ -209,11 +251,43 @@ class AlbumInfo(AlbumInfoBaseClass):
|
||||
)
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
def sort_order(self):
|
||||
"""return sort order of album"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return AlbumSortOrder.MANUAL
|
||||
|
||||
details = self._db._dbalbum_details[self._uuid]
|
||||
if details["customsortkey"] == 1:
|
||||
if details["customsortascending"] == 0:
|
||||
return AlbumSortOrder.NEWEST_FIRST
|
||||
elif details["customsortascending"] == 1:
|
||||
return AlbumSortOrder.OLDEST_FIRST
|
||||
else:
|
||||
return AlbumSortOrder.UNKNOWN
|
||||
elif details["customsortkey"] == 5:
|
||||
return AlbumSortOrder.TITLE
|
||||
elif details["customsortkey"] == 0:
|
||||
return AlbumSortOrder.MANUAL
|
||||
else:
|
||||
return AlbumSortOrder.UNKNOWN
|
||||
|
||||
def photo_index(self, photo):
|
||||
"""return index of photo in album (based on album sort order)"""
|
||||
for index, p in enumerate(self.photos):
|
||||
if p.uuid == photo.uuid:
|
||||
return index
|
||||
raise ValueError(
|
||||
f"Photo with uuid {photo.uuid} does not appear to be in this album"
|
||||
)
|
||||
|
||||
|
||||
class ImportInfo(AlbumInfoBaseClass):
|
||||
"""Information about import sessions"""
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
""" return list of photos contained in import session """
|
||||
"""return list of photos contained in import session"""
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
@@ -229,9 +303,18 @@ class ImportInfo(AlbumInfoBaseClass):
|
||||
return self._photos
|
||||
|
||||
|
||||
class ProjectInfo(AlbumInfo):
|
||||
"""
|
||||
ProjectInfo with info about projects
|
||||
Projects are cards, calendars, slideshows, etc.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class FolderInfo:
|
||||
"""
|
||||
Info about a specific folder, contains all the details about the folder
|
||||
Info about a specific folder, contains all the details about the folder
|
||||
including folders, albums, etc
|
||||
"""
|
||||
|
||||
@@ -247,17 +330,17 @@ class FolderInfo:
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
""" return title / name of folder"""
|
||||
"""return title / name of folder"""
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def uuid(self):
|
||||
""" return uuid of folder """
|
||||
"""return uuid of folder"""
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def album_info(self):
|
||||
""" return list of albums (as AlbumInfo objects) contained in the folder """
|
||||
"""return list of albums (as AlbumInfo objects) contained in the folder"""
|
||||
try:
|
||||
return self._albums
|
||||
except AttributeError:
|
||||
@@ -282,7 +365,7 @@ class FolderInfo:
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
""" returns FolderInfo object for parent or None if no parent (e.g. top-level folder) """
|
||||
"""returns FolderInfo object for parent or None if no parent (e.g. top-level folder)"""
|
||||
try:
|
||||
return self._parent
|
||||
except AttributeError:
|
||||
@@ -290,7 +373,7 @@ class FolderInfo:
|
||||
parent_uuid = self._db._dbfolder_details[self._uuid]["parentFolderUuid"]
|
||||
self._parent = (
|
||||
FolderInfo(db=self._db, uuid=parent_uuid)
|
||||
if parent_uuid != _PHOTOS_4_TOP_LEVEL_ALBUM
|
||||
if parent_uuid not in _PHOTOS_4_TOP_LEVEL_ALBUMS
|
||||
else None
|
||||
)
|
||||
else:
|
||||
@@ -304,7 +387,7 @@ class FolderInfo:
|
||||
|
||||
@property
|
||||
def subfolders(self):
|
||||
""" return list of folders (as FolderInfo objects) contained in the folder """
|
||||
"""return list of folders (as FolderInfo objects) contained in the folder"""
|
||||
try:
|
||||
return self._folders
|
||||
except AttributeError:
|
||||
@@ -328,5 +411,5 @@ class FolderInfo:
|
||||
return self._folders
|
||||
|
||||
def __len__(self):
|
||||
""" returns count of folders + albums contained in the folder """
|
||||
"""returns count of folders + albums contained in the folder"""
|
||||
return len(self.subfolders) + len(self.album_info)
|
||||
|
||||
3623
osxphotos/cli.py
3623
osxphotos/cli.py
File diff suppressed because it is too large
Load Diff
56
osxphotos/cli/__init__.py
Normal file
56
osxphotos/cli/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""cli package for osxphotos"""
|
||||
|
||||
from rich.traceback import install as install_traceback
|
||||
|
||||
from .about import about
|
||||
from .albums import albums
|
||||
from .cli import cli_main
|
||||
from .common import get_photos_db, load_uuid_from_file, set_debug
|
||||
from .debug_dump import debug_dump
|
||||
from .dump import dump
|
||||
from .export import export
|
||||
from .exportdb import exportdb
|
||||
from .grep import grep
|
||||
from .help import help
|
||||
from .info import info
|
||||
from .install_uninstall_run import install, run, uninstall
|
||||
from .keywords import keywords
|
||||
from .labels import labels
|
||||
from .list import _list_libraries, list_libraries
|
||||
from .persons import persons
|
||||
from .places import places
|
||||
from .query import query
|
||||
from .repl import repl
|
||||
from .snap_diff import diff, snap
|
||||
from .tutorial import tutorial
|
||||
from .uuid import uuid
|
||||
|
||||
install_traceback()
|
||||
|
||||
__all__ = [
|
||||
"about",
|
||||
"albums",
|
||||
"cli_main",
|
||||
"debug_dump",
|
||||
"diff",
|
||||
"dump",
|
||||
"export",
|
||||
"exportdb",
|
||||
"grep",
|
||||
"help",
|
||||
"info",
|
||||
"install",
|
||||
"keywords",
|
||||
"labels",
|
||||
"list_libraries",
|
||||
"list_libraries",
|
||||
"load_uuid_from_file",
|
||||
"persons",
|
||||
"places",
|
||||
"query",
|
||||
"repl",
|
||||
"run",
|
||||
"snap",
|
||||
"tutorial",
|
||||
"uuid",
|
||||
]
|
||||
66
osxphotos/cli/about.py
Normal file
66
osxphotos/cli/about.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""about command for osxphotos CLI"""
|
||||
|
||||
import click
|
||||
|
||||
from osxphotos._constants import OSXPHOTOS_URL
|
||||
from osxphotos._version import __version__
|
||||
|
||||
|
||||
@click.command(name="about")
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def about(ctx, cli_obj):
|
||||
"""Print information about osxphotos including license."""
|
||||
license = """
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019-2021 Rhet Turnbull
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
osxphotos uses the following 3rd party software licensed under the BSD-3-Clause License:
|
||||
Click (Copyright 2014 Pallets), ptpython (Copyright (c) 2015, Jonathan Slenders)
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are
|
||||
permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list
|
||||
of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list
|
||||
of conditions and the following disclaimer in the documentation and/or other materials
|
||||
provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without specific prior
|
||||
written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
|
||||
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER
|
||||
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
"""
|
||||
click.echo(f"osxphotos, version {__version__}")
|
||||
click.echo("")
|
||||
click.echo(f"Source code available at: {OSXPHOTOS_URL}")
|
||||
click.echo(license)
|
||||
42
osxphotos/cli/albums.py
Normal file
42
osxphotos/cli/albums.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""albums command for osxphotos CLI"""
|
||||
|
||||
import json
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
import osxphotos
|
||||
|
||||
from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db
|
||||
from .list import _list_libraries
|
||||
|
||||
from osxphotos._constants import _PHOTOS_4_VERSION
|
||||
|
||||
|
||||
@click.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def albums(ctx, cli_obj, db, json_, photos_library):
|
||||
"""Print out albums found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(ctx.obj.group.commands["albums"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
albums = {"albums": photosdb.albums_as_dict}
|
||||
if photosdb.db_version > _PHOTOS_4_VERSION:
|
||||
albums["shared albums"] = photosdb.albums_shared_as_dict
|
||||
|
||||
if json_ or cli_obj.json:
|
||||
click.echo(json.dumps(albums, ensure_ascii=False))
|
||||
else:
|
||||
click.echo(yaml.dump(albums, sort_keys=False, allow_unicode=True))
|
||||
85
osxphotos/cli/cli.py
Normal file
85
osxphotos/cli/cli.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Command line interface for osxphotos """
|
||||
|
||||
import click
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._version import __version__
|
||||
|
||||
from .about import about
|
||||
from .albums import albums
|
||||
from .common import DB_OPTION, JSON_OPTION, OSXPHOTOS_HIDDEN
|
||||
from .debug_dump import debug_dump
|
||||
from .dump import dump
|
||||
from .export import export
|
||||
from .exportdb import exportdb
|
||||
from .grep import grep
|
||||
from .help import help
|
||||
from .info import info
|
||||
from .install_uninstall_run import install, run, uninstall
|
||||
from .keywords import keywords
|
||||
from .labels import labels
|
||||
from .list import _list_libraries, list_libraries
|
||||
from .persons import persons
|
||||
from .places import places
|
||||
from .query import query
|
||||
from .repl import repl
|
||||
from .snap_diff import diff, snap
|
||||
from .tutorial import tutorial
|
||||
from .uuid import uuid
|
||||
|
||||
|
||||
# Click CLI object & context settings
|
||||
class CLI_Obj:
|
||||
def __init__(self, db=None, json=False, debug=False, group=None):
|
||||
if debug:
|
||||
osxphotos._set_debug(True)
|
||||
self.db = db
|
||||
self.json = json
|
||||
self.group = group
|
||||
|
||||
|
||||
CTX_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
|
||||
@click.group(context_settings=CTX_SETTINGS)
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@click.option(
|
||||
"--debug",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
help="Enable debug output",
|
||||
hidden=OSXPHOTOS_HIDDEN,
|
||||
)
|
||||
@click.version_option(__version__, "--version", "-v")
|
||||
@click.pass_context
|
||||
def cli_main(ctx, db, json_, debug):
|
||||
ctx.obj = CLI_Obj(db=db, json=json_, group=cli_main)
|
||||
|
||||
|
||||
# install CLI commands
|
||||
for command in [
|
||||
about,
|
||||
albums,
|
||||
debug_dump,
|
||||
diff,
|
||||
dump,
|
||||
export,
|
||||
exportdb,
|
||||
grep,
|
||||
help,
|
||||
info,
|
||||
install,
|
||||
keywords,
|
||||
labels,
|
||||
list_libraries,
|
||||
persons,
|
||||
places,
|
||||
query,
|
||||
repl,
|
||||
snap,
|
||||
tutorial,
|
||||
uninstall,
|
||||
uuid,
|
||||
]:
|
||||
cli_main.add_command(command)
|
||||
538
osxphotos/cli/common.py
Normal file
538
osxphotos/cli/common.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""Globals and constants use by the CLI commands"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Callable
|
||||
|
||||
import click
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._version import __version__
|
||||
|
||||
from .param_types import *
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
# global variable to control debug output
|
||||
# set via --debug
|
||||
DEBUG = False
|
||||
|
||||
# used to show/hide hidden commands
|
||||
OSXPHOTOS_HIDDEN = not bool(os.getenv("OSXPHOTOS_SHOW_HIDDEN", default=False))
|
||||
|
||||
# used by snap and diff commands
|
||||
OSXPHOTOS_SNAPSHOT_DIR = "/private/tmp/osxphotos_snapshots"
|
||||
|
||||
# where to write the crash report if osxphotos crashes
|
||||
OSXPHOTOS_CRASH_LOG = os.getcwd() + "/osxphotos_crash.log"
|
||||
|
||||
CLI_COLOR_ERROR = "red"
|
||||
CLI_COLOR_WARNING = "yellow"
|
||||
|
||||
|
||||
def set_debug(debug: bool):
|
||||
"""set debug flag"""
|
||||
global DEBUG
|
||||
DEBUG = debug
|
||||
|
||||
|
||||
def is_debug():
|
||||
"""return debug flag"""
|
||||
return DEBUG
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
"""no-op function"""
|
||||
pass
|
||||
|
||||
|
||||
def verbose_print(
|
||||
verbose: bool = True, timestamp: bool = False, rich=False
|
||||
) -> Callable:
|
||||
"""Create verbose function to print output
|
||||
|
||||
Args:
|
||||
verbose: if True, returns verbose print function otherwise returns no-op function
|
||||
timestamp: if True, includes timestamp in verbose output
|
||||
rich: use rich.print instead of click.echo
|
||||
|
||||
Returns:
|
||||
function to print output
|
||||
"""
|
||||
if not verbose:
|
||||
return noop
|
||||
|
||||
# closure to capture timestamp
|
||||
def verbose_(*args, **kwargs):
|
||||
"""print output if verbose flag set"""
|
||||
styled_args = []
|
||||
timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else ""
|
||||
for arg in args:
|
||||
if type(arg) == str:
|
||||
arg = timestamp_str + arg
|
||||
if "error" in arg.lower():
|
||||
arg = click.style(arg, fg=CLI_COLOR_ERROR)
|
||||
elif "warning" in arg.lower():
|
||||
arg = click.style(arg, fg=CLI_COLOR_WARNING)
|
||||
styled_args.append(arg)
|
||||
click.echo(*styled_args, **kwargs)
|
||||
|
||||
def rich_verbose_(*args, **kwargs):
|
||||
"""print output if verbose flag set using rich.print"""
|
||||
timestamp_str = str(datetime.datetime.now()) + " -- " if timestamp else ""
|
||||
for arg in args:
|
||||
if type(arg) == str:
|
||||
arg = timestamp_str + arg
|
||||
if "error" in arg.lower():
|
||||
arg = f"[{CLI_COLOR_ERROR}]{arg}[/{CLI_COLOR_ERROR}]"
|
||||
elif "warning" in arg.lower():
|
||||
arg = f"[{CLI_COLOR_WARNING}]{arg}[/{CLI_COLOR_WARNING}]"
|
||||
rprint(arg, **kwargs)
|
||||
|
||||
return rich_verbose_ if rich else verbose_
|
||||
|
||||
|
||||
def get_photos_db(*db_options):
|
||||
"""Return path to photos db, select first non-None db_options
|
||||
If no db_options are non-None, try to find library to use in
|
||||
the following order:
|
||||
- last library opened
|
||||
- system library
|
||||
- ~/Pictures/Photos Library.photoslibrary
|
||||
- failing above, returns None
|
||||
"""
|
||||
if db_options:
|
||||
for db in db_options:
|
||||
if db is not None:
|
||||
return db
|
||||
|
||||
# if get here, no valid database paths passed, so try to figure out which to use
|
||||
db = osxphotos.utils.get_last_library_path()
|
||||
if db is not None:
|
||||
click.echo(f"Using last opened Photos library: {db}", err=True)
|
||||
return db
|
||||
|
||||
db = osxphotos.utils.get_system_library_path()
|
||||
if db is not None:
|
||||
click.echo(f"Using system Photos library: {db}", err=True)
|
||||
return db
|
||||
|
||||
db = os.path.expanduser("~/Pictures/Photos Library.photoslibrary")
|
||||
if os.path.isdir(db):
|
||||
click.echo(f"Using Photos library: {db}", err=True)
|
||||
return db
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
DB_OPTION = click.option(
|
||||
"--db",
|
||||
required=False,
|
||||
metavar="<Photos database path>",
|
||||
default=None,
|
||||
help=(
|
||||
"Specify Photos database path. "
|
||||
"Path to Photos library/database can be specified using either --db "
|
||||
"or directly as PHOTOS_LIBRARY positional argument. "
|
||||
"If neither --db or PHOTOS_LIBRARY provided, will attempt to find the library "
|
||||
"to use in the following order: 1. last opened library, 2. system library, 3. ~/Pictures/Photos Library.photoslibrary"
|
||||
),
|
||||
type=click.Path(exists=True),
|
||||
)
|
||||
|
||||
DB_ARGUMENT = click.argument("photos_library", nargs=-1, type=click.Path(exists=True))
|
||||
|
||||
JSON_OPTION = click.option(
|
||||
"--json",
|
||||
"json_",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print output in JSON format.",
|
||||
)
|
||||
|
||||
|
||||
def DELETED_OPTIONS(f):
|
||||
o = click.option
|
||||
options = [
|
||||
o(
|
||||
"--deleted",
|
||||
is_flag=True,
|
||||
help="Include photos from the 'Recently Deleted' folder.",
|
||||
),
|
||||
o(
|
||||
"--deleted-only",
|
||||
is_flag=True,
|
||||
help="Include only photos from the 'Recently Deleted' folder.",
|
||||
),
|
||||
]
|
||||
for o in options[::-1]:
|
||||
f = o(f)
|
||||
return f
|
||||
|
||||
|
||||
def QUERY_OPTIONS(f):
|
||||
o = click.option
|
||||
options = [
|
||||
o(
|
||||
"--keyword",
|
||||
metavar="KEYWORD",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos with keyword KEYWORD. "
|
||||
'If more than one keyword, treated as "OR", e.g. find photos matching any keyword',
|
||||
),
|
||||
o(
|
||||
"--person",
|
||||
metavar="PERSON",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos with person PERSON. "
|
||||
'If more than one person, treated as "OR", e.g. find photos matching any person',
|
||||
),
|
||||
o(
|
||||
"--album",
|
||||
metavar="ALBUM",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos in album ALBUM. "
|
||||
'If more than one album, treated as "OR", e.g. find photos matching any album',
|
||||
),
|
||||
o(
|
||||
"--folder",
|
||||
metavar="FOLDER",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos in an album in folder FOLDER. "
|
||||
'If more than one folder, treated as "OR", e.g. find photos in any FOLDER. '
|
||||
"Only searches top level folders (e.g. does not look at subfolders)",
|
||||
),
|
||||
o(
|
||||
"--name",
|
||||
metavar="FILENAME",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos with filename matching FILENAME. "
|
||||
'If more than one --name options is specified, they are treated as "OR", '
|
||||
"e.g. find photos matching any FILENAME. ",
|
||||
),
|
||||
o(
|
||||
"--uuid",
|
||||
metavar="UUID",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for photos with UUID(s). "
|
||||
"May be repeated to include multiple UUIDs.",
|
||||
),
|
||||
o(
|
||||
"--uuid-from-file",
|
||||
metavar="FILE",
|
||||
default=None,
|
||||
multiple=False,
|
||||
help="Search for photos with UUID(s) loaded from FILE. "
|
||||
"Format is a single UUID per line. Lines preceded with # are ignored.",
|
||||
type=click.Path(exists=True),
|
||||
),
|
||||
o(
|
||||
"--title",
|
||||
metavar="TITLE",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for TITLE in title of photo.",
|
||||
),
|
||||
o("--no-title", is_flag=True, help="Search for photos with no title."),
|
||||
o(
|
||||
"--description",
|
||||
metavar="DESC",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for DESC in description of photo.",
|
||||
),
|
||||
o(
|
||||
"--no-description",
|
||||
is_flag=True,
|
||||
help="Search for photos with no description.",
|
||||
),
|
||||
o(
|
||||
"--place",
|
||||
metavar="PLACE",
|
||||
default=None,
|
||||
multiple=True,
|
||||
help="Search for PLACE in photo's reverse geolocation info",
|
||||
),
|
||||
o(
|
||||
"--no-place",
|
||||
is_flag=True,
|
||||
help="Search for photos with no associated place name info (no reverse geolocation info)",
|
||||
),
|
||||
o(
|
||||
"--location",
|
||||
is_flag=True,
|
||||
help="Search for photos with associated location info (e.g. GPS coordinates)",
|
||||
),
|
||||
o(
|
||||
"--no-location",
|
||||
is_flag=True,
|
||||
help="Search for photos with no associated location info (e.g. no GPS coordinates)",
|
||||
),
|
||||
o(
|
||||
"--label",
|
||||
metavar="LABEL",
|
||||
multiple=True,
|
||||
help="Search for photos with image classification label LABEL (Photos 5 only). "
|
||||
'If more than one label, treated as "OR", e.g. find photos matching any label',
|
||||
),
|
||||
o(
|
||||
"--uti",
|
||||
metavar="UTI",
|
||||
default=None,
|
||||
multiple=False,
|
||||
help="Search for photos whose uniform type identifier (UTI) matches UTI",
|
||||
),
|
||||
o(
|
||||
"-i",
|
||||
"--ignore-case",
|
||||
is_flag=True,
|
||||
help="Case insensitive search for title, description, place, keyword, person, or album.",
|
||||
),
|
||||
o("--edited", is_flag=True, help="Search for photos that have been edited."),
|
||||
o(
|
||||
"--external-edit",
|
||||
is_flag=True,
|
||||
help="Search for photos edited in external editor.",
|
||||
),
|
||||
o("--favorite", is_flag=True, help="Search for photos marked favorite."),
|
||||
o(
|
||||
"--not-favorite",
|
||||
is_flag=True,
|
||||
help="Search for photos not marked favorite.",
|
||||
),
|
||||
o("--hidden", is_flag=True, help="Search for photos marked hidden."),
|
||||
o("--not-hidden", is_flag=True, help="Search for photos not marked hidden."),
|
||||
o(
|
||||
"--shared",
|
||||
is_flag=True,
|
||||
help="Search for photos in shared iCloud album (Photos 5 only).",
|
||||
),
|
||||
o(
|
||||
"--not-shared",
|
||||
is_flag=True,
|
||||
help="Search for photos not in shared iCloud album (Photos 5 only).",
|
||||
),
|
||||
o(
|
||||
"--burst",
|
||||
is_flag=True,
|
||||
help="Search for photos that were taken in a burst.",
|
||||
),
|
||||
o(
|
||||
"--not-burst",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not part of a burst.",
|
||||
),
|
||||
o("--live", is_flag=True, help="Search for Apple live photos"),
|
||||
o(
|
||||
"--not-live",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not Apple live photos.",
|
||||
),
|
||||
o("--portrait", is_flag=True, help="Search for Apple portrait mode photos."),
|
||||
o(
|
||||
"--not-portrait",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not Apple portrait mode photos.",
|
||||
),
|
||||
o("--screenshot", is_flag=True, help="Search for screenshot photos."),
|
||||
o(
|
||||
"--not-screenshot",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not screenshot photos.",
|
||||
),
|
||||
o("--slow-mo", is_flag=True, help="Search for slow motion videos."),
|
||||
o(
|
||||
"--not-slow-mo",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not slow motion videos.",
|
||||
),
|
||||
o("--time-lapse", is_flag=True, help="Search for time lapse videos."),
|
||||
o(
|
||||
"--not-time-lapse",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not time lapse videos.",
|
||||
),
|
||||
o("--hdr", is_flag=True, help="Search for high dynamic range (HDR) photos."),
|
||||
o("--not-hdr", is_flag=True, help="Search for photos that are not HDR photos."),
|
||||
o(
|
||||
"--selfie",
|
||||
is_flag=True,
|
||||
help="Search for selfies (photos taken with front-facing cameras).",
|
||||
),
|
||||
o("--not-selfie", is_flag=True, help="Search for photos that are not selfies."),
|
||||
o("--panorama", is_flag=True, help="Search for panorama photos."),
|
||||
o(
|
||||
"--not-panorama",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not panoramas.",
|
||||
),
|
||||
o(
|
||||
"--has-raw",
|
||||
is_flag=True,
|
||||
help="Search for photos with both a jpeg and raw version",
|
||||
),
|
||||
o(
|
||||
"--only-movies",
|
||||
is_flag=True,
|
||||
help="Search only for movies (default searches both images and movies).",
|
||||
),
|
||||
o(
|
||||
"--only-photos",
|
||||
is_flag=True,
|
||||
help="Search only for photos/images (default searches both images and movies).",
|
||||
),
|
||||
o(
|
||||
"--from-date",
|
||||
help="Search by item start date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).",
|
||||
type=DateTimeISO8601(),
|
||||
),
|
||||
o(
|
||||
"--to-date",
|
||||
help="Search by item end date, e.g. 2000-01-12T12:00:00, 2001-01-12T12:00:00-07:00, or 2000-12-31 (ISO 8601 with/without timezone).",
|
||||
type=DateTimeISO8601(),
|
||||
),
|
||||
o(
|
||||
"--from-time",
|
||||
help="Search by item start time of day, e.g. 12:00, or 12:00:00.",
|
||||
type=TimeISO8601(),
|
||||
),
|
||||
o(
|
||||
"--to-time",
|
||||
help="Search by item end time of day, e.g. 12:00 or 12:00:00.",
|
||||
type=TimeISO8601(),
|
||||
),
|
||||
o("--has-comment", is_flag=True, help="Search for photos that have comments."),
|
||||
o("--no-comment", is_flag=True, help="Search for photos with no comments."),
|
||||
o("--has-likes", is_flag=True, help="Search for photos that have likes."),
|
||||
o("--no-likes", is_flag=True, help="Search for photos with no likes."),
|
||||
o(
|
||||
"--is-reference",
|
||||
is_flag=True,
|
||||
help="Search for photos that were imported as referenced files (not copied into Photos library).",
|
||||
),
|
||||
o(
|
||||
"--in-album",
|
||||
is_flag=True,
|
||||
help="Search for photos that are in one or more albums.",
|
||||
),
|
||||
o(
|
||||
"--not-in-album",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not in any albums.",
|
||||
),
|
||||
o(
|
||||
"--duplicate",
|
||||
is_flag=True,
|
||||
help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, "
|
||||
"evaluating date created, size, height, width, and edited status to find *possible* duplicates. "
|
||||
"This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple "
|
||||
"times or duplicated within Photos.",
|
||||
),
|
||||
o(
|
||||
"--min-size",
|
||||
metavar="SIZE",
|
||||
type=BitMathSize(),
|
||||
help="Search for photos with size >= SIZE bytes. "
|
||||
"The size evaluated is the photo's original size (when imported to Photos). "
|
||||
"Size may be specified as integer bytes or using SI or NIST units. "
|
||||
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
|
||||
),
|
||||
o(
|
||||
"--max-size",
|
||||
metavar="SIZE",
|
||||
type=BitMathSize(),
|
||||
help="Search for photos with size <= SIZE bytes. "
|
||||
"The size evaluated is the photo's original size (when imported to Photos). "
|
||||
"Size may be specified as integer bytes or using SI or NIST units. "
|
||||
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
|
||||
),
|
||||
o(
|
||||
"--regex",
|
||||
metavar="REGEX TEMPLATE",
|
||||
nargs=2,
|
||||
multiple=True,
|
||||
help="Search for photos where TEMPLATE matches regular expression REGEX. "
|
||||
"For example, to find photos in an album that begins with 'Beach': '--regex \"^Beach\" \"{album}\"'. "
|
||||
"You may specify more than one regular expression match by repeating '--regex' with different arguments.",
|
||||
),
|
||||
o(
|
||||
"--selected",
|
||||
is_flag=True,
|
||||
help="Filter for photos that are currently selected in Photos.",
|
||||
),
|
||||
o(
|
||||
"--exif",
|
||||
metavar="EXIF_TAG VALUE",
|
||||
nargs=2,
|
||||
multiple=True,
|
||||
help="Search for photos where EXIF_TAG exists in photo's EXIF data and contains VALUE. "
|
||||
"For example, to find photos created by Adobe Photoshop: `--exif Software 'Adobe Photoshop' `"
|
||||
"or to find all photos shot on a Canon camera: `--exif Make Canon`. "
|
||||
"EXIF_TAG can be any valid exiftool tag, with or without group name, e.g. `EXIF:Make` or `Make`. "
|
||||
"To use --exif, exiftool must be installed and in the path.",
|
||||
),
|
||||
o(
|
||||
"--query-eval",
|
||||
metavar="CRITERIA",
|
||||
multiple=True,
|
||||
help="Evaluate CRITERIA to filter photos. "
|
||||
"CRITERIA will be evaluated in context of the following python list comprehension: "
|
||||
"`photos = [photo for photo in photos if CRITERIA]` "
|
||||
"where photo represents a PhotoInfo object. "
|
||||
"For example: `--query-eval photo.favorite` returns all photos that have been "
|
||||
"favorited and is equivalent to --favorite. "
|
||||
"You may specify more than one CRITERIA by using --query-eval multiple times. "
|
||||
"CRITERIA must be a valid python expression. "
|
||||
"See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
|
||||
),
|
||||
o(
|
||||
"--query-function",
|
||||
metavar="filename.py::function",
|
||||
multiple=True,
|
||||
type=FunctionCall(),
|
||||
help="Run function to filter photos. Use this in format: --query-function filename.py::function where filename.py is a python "
|
||||
+ "file you've created and function is the name of the function in the python file you want to call. "
|
||||
+ "Your function will be passed a list of PhotoInfo objects and is expected to return a filtered list of PhotoInfo objects. "
|
||||
+ "You may use more than one function by repeating the --query-function option with a different value. "
|
||||
+ "Your query function will be called after all other query options have been evaluated. "
|
||||
+ "See https://github.com/RhetTbull/osxphotos/blob/master/examples/query_function.py for example of how to use this option.",
|
||||
),
|
||||
]
|
||||
for o in options[::-1]:
|
||||
f = o(f)
|
||||
return f
|
||||
|
||||
|
||||
def load_uuid_from_file(filename):
|
||||
"""Load UUIDs from file. Does not validate UUIDs.
|
||||
Format is 1 UUID per line, any line beginning with # is ignored.
|
||||
Whitespace is stripped.
|
||||
|
||||
Arguments:
|
||||
filename: file name of the file containing UUIDs
|
||||
|
||||
Returns:
|
||||
list of UUIDs or empty list of no UUIDs in file
|
||||
|
||||
Raises:
|
||||
FileNotFoundError if file does not exist
|
||||
"""
|
||||
|
||||
if not pathlib.Path(filename).is_file():
|
||||
raise FileNotFoundError(f"Could not find file {filename}")
|
||||
|
||||
uuid = []
|
||||
with open(filename, "r") as uuid_file:
|
||||
for line in uuid_file:
|
||||
line = line.strip()
|
||||
if len(line) and line[0] != "#":
|
||||
uuid.append(line)
|
||||
return uuid
|
||||
103
osxphotos/cli/debug_dump.py
Normal file
103
osxphotos/cli/debug_dump.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""debug-dump command for osxphotos CLI"""
|
||||
|
||||
import pprint
|
||||
import time
|
||||
|
||||
import click
|
||||
from rich import print
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _PHOTOS_4_VERSION, _UNKNOWN_PLACE
|
||||
|
||||
from .common import (
|
||||
DB_ARGUMENT,
|
||||
DB_OPTION,
|
||||
JSON_OPTION,
|
||||
OSXPHOTOS_HIDDEN,
|
||||
get_photos_db,
|
||||
verbose_print,
|
||||
)
|
||||
from .list import _list_libraries
|
||||
|
||||
|
||||
@click.command(hidden=OSXPHOTOS_HIDDEN)
|
||||
@DB_OPTION
|
||||
@DB_ARGUMENT
|
||||
@click.option(
|
||||
"--dump",
|
||||
metavar="ATTR",
|
||||
help="Name of PhotosDB attribute to print; "
|
||||
+ "can also use albums, persons, keywords, photos to dump related attributes.",
|
||||
multiple=True,
|
||||
)
|
||||
@click.option(
|
||||
"--uuid",
|
||||
metavar="UUID",
|
||||
help="Use with '--dump photos' to dump only certain UUIDs. "
|
||||
"May be repeated to include multiple UUIDs.",
|
||||
multiple=True,
|
||||
)
|
||||
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
|
||||
"""Print out debug info"""
|
||||
|
||||
verbose_ = verbose_print(verbose, rich=True)
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
if db is None:
|
||||
click.echo(ctx.obj.group.commands["debug-dump"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
start_t = time.perf_counter()
|
||||
print(f"Opening database: {db}")
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
|
||||
stop_t = time.perf_counter()
|
||||
print(f"Done; took {(stop_t-start_t):.2f} seconds")
|
||||
|
||||
for attr in dump:
|
||||
if attr == "albums":
|
||||
print("_dbalbums_album:")
|
||||
pprint.pprint(photosdb._dbalbums_album)
|
||||
print("_dbalbums_uuid:")
|
||||
pprint.pprint(photosdb._dbalbums_uuid)
|
||||
print("_dbalbum_details:")
|
||||
pprint.pprint(photosdb._dbalbum_details)
|
||||
print("_dbalbum_folders:")
|
||||
pprint.pprint(photosdb._dbalbum_folders)
|
||||
print("_dbfolder_details:")
|
||||
pprint.pprint(photosdb._dbfolder_details)
|
||||
elif attr == "keywords":
|
||||
print("_dbkeywords_keyword:")
|
||||
pprint.pprint(photosdb._dbkeywords_keyword)
|
||||
print("_dbkeywords_uuid:")
|
||||
pprint.pprint(photosdb._dbkeywords_uuid)
|
||||
elif attr == "persons":
|
||||
print("_dbfaces_uuid:")
|
||||
pprint.pprint(photosdb._dbfaces_uuid)
|
||||
print("_dbfaces_pk:")
|
||||
pprint.pprint(photosdb._dbfaces_pk)
|
||||
print("_dbpersons_pk:")
|
||||
pprint.pprint(photosdb._dbpersons_pk)
|
||||
print("_dbpersons_fullname:")
|
||||
pprint.pprint(photosdb._dbpersons_fullname)
|
||||
elif attr == "photos":
|
||||
if uuid:
|
||||
for uuid_ in uuid:
|
||||
print(f"_dbphotos['{uuid_}']:")
|
||||
try:
|
||||
pprint.pprint(photosdb._dbphotos[uuid_])
|
||||
except KeyError:
|
||||
print(f"Did not find uuid {uuid_} in _dbphotos")
|
||||
else:
|
||||
print("_dbphotos:")
|
||||
pprint.pprint(photosdb._dbphotos)
|
||||
else:
|
||||
try:
|
||||
val = getattr(photosdb, attr)
|
||||
print(f"{attr}:")
|
||||
pprint.pprint(val)
|
||||
except Exception:
|
||||
print(f"Did not find attribute {attr} in PhotosDB")
|
||||
44
osxphotos/cli/dump.py
Normal file
44
osxphotos/cli/dump.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""dump command for osxphotos CLI """
|
||||
|
||||
import click
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.queryoptions import QueryOptions
|
||||
|
||||
from .common import DB_ARGUMENT, DB_OPTION, DELETED_OPTIONS, JSON_OPTION, get_photos_db
|
||||
from .list import _list_libraries
|
||||
from .print_photo_info import print_photo_info
|
||||
|
||||
|
||||
@click.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@DELETED_OPTIONS
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
|
||||
"""Print list of all photos & associated info from the Photos library."""
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
if db is None:
|
||||
click.echo(ctx.obj.group.commands["dump"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
# check exclusive options
|
||||
if deleted and deleted_only:
|
||||
click.echo("Incompatible dump options", err=True)
|
||||
click.echo(ctx.obj.group.commands["dump"].get_help(ctx), err=True)
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
if deleted or deleted_only:
|
||||
photos = photosdb.photos(movies=True, intrash=True)
|
||||
else:
|
||||
photos = []
|
||||
if not deleted_only:
|
||||
photos += photosdb.photos(movies=True)
|
||||
|
||||
print_photo_info(photos, json_ or cli_obj.json)
|
||||
2767
osxphotos/cli/export.py
Normal file
2767
osxphotos/cli/export.py
Normal file
File diff suppressed because it is too large
Load Diff
251
osxphotos/cli/exportdb.py
Normal file
251
osxphotos/cli/exportdb.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""exportdb command for osxphotos CLI"""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import click
|
||||
from rich import print
|
||||
|
||||
from osxphotos._constants import OSXPHOTOS_EXPORT_DB
|
||||
from osxphotos._version import __version__
|
||||
from osxphotos.export_db import OSXPHOTOS_EXPORTDB_VERSION, ExportDB
|
||||
from osxphotos.export_db_utils import (
|
||||
export_db_check_signatures,
|
||||
export_db_get_last_run,
|
||||
export_db_get_version,
|
||||
export_db_save_config_to_file,
|
||||
export_db_touch_files,
|
||||
export_db_update_signatures,
|
||||
export_db_vacuum,
|
||||
)
|
||||
|
||||
from .common import OSXPHOTOS_HIDDEN, verbose_print
|
||||
|
||||
|
||||
@click.command(name="exportdb", hidden=OSXPHOTOS_HIDDEN)
|
||||
@click.option("--version", is_flag=True, help="Print export database version and exit.")
|
||||
@click.option("--vacuum", is_flag=True, help="Run VACUUM to defragment the database.")
|
||||
@click.option(
|
||||
"--check-signatures",
|
||||
is_flag=True,
|
||||
help="Check signatures for all exported photos in the database to find signatures that don't match.",
|
||||
)
|
||||
@click.option(
|
||||
"--update-signatures",
|
||||
is_flag=True,
|
||||
help="Update signatures for all exported photos in the database to match on-disk signatures.",
|
||||
)
|
||||
@click.option(
|
||||
"--touch-file",
|
||||
is_flag=True,
|
||||
help="Touch files on disk to match created date in Photos library and update export database signatures",
|
||||
)
|
||||
@click.option(
|
||||
"--last-run",
|
||||
is_flag=True,
|
||||
help="Show last run osxphotos commands used with this database.",
|
||||
)
|
||||
@click.option(
|
||||
"--save-config",
|
||||
metavar="CONFIG_FILE",
|
||||
help="Save last run configuration to TOML file for use by --load-config.",
|
||||
)
|
||||
@click.option(
|
||||
"--info",
|
||||
metavar="FILE_PATH",
|
||||
nargs=1,
|
||||
help="Print information about FILE_PATH contained in the database.",
|
||||
)
|
||||
@click.option(
|
||||
"--migrate",
|
||||
is_flag=True,
|
||||
help="Migrate (if needed) export database to current version.",
|
||||
)
|
||||
@click.option(
|
||||
"--sql",
|
||||
metavar="SQL_STATEMENT",
|
||||
help="Execute SQL_STATEMENT against export database and print results.",
|
||||
)
|
||||
@click.option(
|
||||
"--export-dir",
|
||||
help="Optional path to export directory (if not parent of export database).",
|
||||
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
||||
)
|
||||
@click.option("--verbose", "-V", is_flag=True, help="Print verbose output.")
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
help="Run in dry-run mode (don't actually update files), e.g. for use with --update-signatures.",
|
||||
)
|
||||
@click.argument("export_db", metavar="EXPORT_DATABASE", type=click.Path(exists=True))
|
||||
def exportdb(
|
||||
version,
|
||||
vacuum,
|
||||
check_signatures,
|
||||
update_signatures,
|
||||
touch_file,
|
||||
last_run,
|
||||
save_config,
|
||||
info,
|
||||
migrate,
|
||||
sql,
|
||||
export_dir,
|
||||
verbose,
|
||||
dry_run,
|
||||
export_db,
|
||||
):
|
||||
"""Utilities for working with the osxphotos export database"""
|
||||
|
||||
verbose_ = verbose_print(verbose, rich=True)
|
||||
|
||||
export_db = pathlib.Path(export_db)
|
||||
if export_db.is_dir():
|
||||
# assume it's the export folder
|
||||
export_db = export_db / OSXPHOTOS_EXPORT_DB
|
||||
if not export_db.is_file():
|
||||
print(
|
||||
f"[red]Error: {OSXPHOTOS_EXPORT_DB} missing from {export_db.parent}[/red]"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
export_dir = export_dir or export_db.parent
|
||||
|
||||
sub_commands = [
|
||||
version,
|
||||
check_signatures,
|
||||
update_signatures,
|
||||
touch_file,
|
||||
last_run,
|
||||
bool(save_config),
|
||||
bool(info),
|
||||
migrate,
|
||||
bool(sql),
|
||||
]
|
||||
if sum(sub_commands) > 1:
|
||||
print("[red]Only a single sub-command may be specified at a time[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
if version:
|
||||
try:
|
||||
osxphotos_ver, export_db_ver = export_db_get_version(export_db)
|
||||
except Exception as e:
|
||||
print(f"[red]Error: could not read version from {export_db}: {e}[/red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(
|
||||
f"osxphotos version: {osxphotos_ver}, export database version: {export_db_ver}"
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
if vacuum:
|
||||
try:
|
||||
start_size = pathlib.Path(export_db).stat().st_size
|
||||
export_db_vacuum(export_db)
|
||||
except Exception as e:
|
||||
print(f"[red]Error: {e}[/red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(
|
||||
f"Vacuumed {export_db}! {start_size} bytes -> {pathlib.Path(export_db).stat().st_size} bytes"
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
if update_signatures:
|
||||
try:
|
||||
updated, skipped = export_db_update_signatures(
|
||||
export_db, export_dir, verbose_, dry_run
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[red]Error: {e}[/red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Done. Updated {updated} files, skipped {skipped} files.")
|
||||
sys.exit(0)
|
||||
|
||||
if last_run:
|
||||
try:
|
||||
last_run_info = export_db_get_last_run(export_db)
|
||||
except Exception as e:
|
||||
print(f"[red]Error: {e}[/red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"last run at {last_run_info[0]}:")
|
||||
print(f"osxphotos {last_run_info[1]}")
|
||||
sys.exit(0)
|
||||
|
||||
if save_config:
|
||||
try:
|
||||
export_db_save_config_to_file(export_db, save_config)
|
||||
except Exception as e:
|
||||
print(f"[red]Error: {e}[/red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Saved configuration to {save_config}")
|
||||
sys.exit(0)
|
||||
|
||||
if check_signatures:
|
||||
try:
|
||||
matched, notmatched, skipped = export_db_check_signatures(
|
||||
export_db, export_dir, verbose_=verbose_
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[red]Error: {e}[/red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(
|
||||
f"Done. Found {matched} matching signatures and {notmatched} signatures that don't match. Skipped {skipped} missing files."
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
if touch_file:
|
||||
try:
|
||||
touched, not_touched, skipped = export_db_touch_files(
|
||||
export_db, export_dir, verbose_=verbose_, dry_run=dry_run
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[red]Error: {e}[/red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(
|
||||
f"Done. Touched {touched} files, skipped {not_touched} up to date files, skipped {skipped} missing files."
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
if info:
|
||||
exportdb = ExportDB(export_db, export_dir)
|
||||
try:
|
||||
info_rec = exportdb.get_file_record(info)
|
||||
except Exception as e:
|
||||
print(f"[red]Error: {e}[/red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
if info_rec:
|
||||
print(info_rec.asdict())
|
||||
else:
|
||||
print(f"[red]File '{info}' not found in export database[/red]")
|
||||
sys.exit(0)
|
||||
|
||||
if migrate:
|
||||
exportdb = ExportDB(export_db, export_dir)
|
||||
if upgraded := exportdb.was_upgraded:
|
||||
print(
|
||||
f"Migrated export database {export_db} from version {upgraded[0]} to {upgraded[1]}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Export database {export_db} is already at latest version {OSXPHOTOS_EXPORTDB_VERSION}"
|
||||
)
|
||||
sys.exit(0)
|
||||
|
||||
if sql:
|
||||
exportdb = ExportDB(export_db, export_dir)
|
||||
try:
|
||||
c = exportdb._conn.cursor()
|
||||
results = c.execute(sql)
|
||||
except Exception as e:
|
||||
print(f"[red]Error: {e}[/red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
for row in results:
|
||||
print(row)
|
||||
sys.exit(0)
|
||||
57
osxphotos/cli/grep.py
Normal file
57
osxphotos/cli/grep.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""grep command for osxphotos CLI """
|
||||
|
||||
import pathlib
|
||||
|
||||
import click
|
||||
from rich import print
|
||||
|
||||
from osxphotos.photosdb.photosdb_utils import get_photos_library_version
|
||||
from osxphotos.sqlgrep import sqlgrep
|
||||
|
||||
from .common import DB_OPTION, OSXPHOTOS_HIDDEN, get_photos_db
|
||||
|
||||
|
||||
@click.command(name="grep", hidden=OSXPHOTOS_HIDDEN)
|
||||
@DB_OPTION
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--ignore-case",
|
||||
"-i",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Ignore case when searching (default is case-sensitive).",
|
||||
)
|
||||
@click.option(
|
||||
"--print-filename",
|
||||
"-p",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print name of database file when printing results.",
|
||||
)
|
||||
@click.argument("pattern", metavar="PATTERN", required=True)
|
||||
def grep(ctx, cli_obj, db, ignore_case, print_filename, pattern):
|
||||
"""Search for PATTERN in the Photos sqlite database file"""
|
||||
db = db or get_photos_db()
|
||||
db = pathlib.Path(db)
|
||||
if db.is_file():
|
||||
# if passed the actual database, really want the parent of the database directory
|
||||
db = db.parent.parent
|
||||
photos_ver = get_photos_library_version(str(db))
|
||||
if photos_ver < 5:
|
||||
db_file = db / "database" / "photos.db"
|
||||
else:
|
||||
db_file = db / "database" / "Photos.sqlite"
|
||||
|
||||
if not db_file.is_file():
|
||||
click.secho(f"Could not find database file {db_file}", fg="red")
|
||||
ctx.exit(2)
|
||||
|
||||
db_file = str(db_file)
|
||||
|
||||
for table, column, row_id, value in sqlgrep(
|
||||
db_file, pattern, ignore_case, print_filename, rich_markup=True
|
||||
):
|
||||
print(", ".join([table, column, row_id, value]))
|
||||
@@ -8,20 +8,56 @@ import osxmetadata
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from ._constants import (
|
||||
from osxphotos._constants import (
|
||||
EXTENDED_ATTRIBUTE_NAMES,
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||
OSXPHOTOS_EXPORT_DB,
|
||||
POST_COMMAND_CATEGORIES,
|
||||
)
|
||||
from .phototemplate import (
|
||||
from osxphotos.phototemplate import (
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
TEMPLATE_SUBSTITUTIONS_PATHLIB,
|
||||
get_template_help,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ExportCommand",
|
||||
"template_help",
|
||||
"rich_text",
|
||||
"strip_md_header_and_links",
|
||||
"strip_md_links",
|
||||
"strip_html_comments",
|
||||
"help",
|
||||
"get_help_msg",
|
||||
]
|
||||
|
||||
|
||||
def get_help_msg(command):
|
||||
"""get help message for a Click command"""
|
||||
with click.Context(command) as ctx:
|
||||
return command.get_help(ctx)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("topic", default=None, required=False, nargs=1)
|
||||
@click.pass_context
|
||||
def help(ctx, topic, **kw):
|
||||
"""Print help; for help on commands: help <command>."""
|
||||
if topic is None:
|
||||
click.echo(ctx.parent.get_help())
|
||||
return
|
||||
elif topic in ctx.obj.group.commands:
|
||||
ctx.info_name = topic
|
||||
click.echo_via_pager(ctx.obj.group.commands[topic].get_help(ctx))
|
||||
else:
|
||||
click.echo(f"Invalid command: {topic}", err=True)
|
||||
click.echo(ctx.parent.get_help())
|
||||
|
||||
|
||||
# TODO: The following help text could probably be done as mako template
|
||||
class ExportCommand(click.Command):
|
||||
""" Custom click.Command that overrides get_help() to show additional help info for export """
|
||||
"""Custom click.Command that overrides get_help() to show additional help info for export"""
|
||||
|
||||
def get_help(self, ctx):
|
||||
help_text = super().get_help(ctx)
|
||||
@@ -65,7 +101,9 @@ class ExportCommand(click.Command):
|
||||
+ f"rebuilding the '{OSXPHOTOS_EXPORT_DB}' database."
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write(rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width))
|
||||
formatter.write(
|
||||
rich_text("[bold]** Extended Attributes **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"""
|
||||
@@ -99,7 +137,9 @@ The following attributes may be used with '--xattr-template':
|
||||
"For additional information on extended attributes see: https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys"
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write(rich_text("[bold]** Templating System **[/bold]", width=formatter.width))
|
||||
formatter.write(
|
||||
rich_text("[bold]** Templating System **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(template_help(width=formatter.width))
|
||||
formatter.write("\n")
|
||||
@@ -128,7 +168,11 @@ The following attributes may be used with '--xattr-template':
|
||||
+ "an error and the script will abort."
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(rich_text("[bold]** Template Substitutions **[/bold]", width=formatter.width))
|
||||
formatter.write(
|
||||
rich_text(
|
||||
"[bold]** Template Substitutions **[/bold]", width=formatter.width
|
||||
)
|
||||
)
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS.items())
|
||||
@@ -151,15 +195,108 @@ The following attributes may be used with '--xattr-template':
|
||||
)
|
||||
|
||||
formatter.write_dl(templ_tuples)
|
||||
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"The following substitutions are file or directory paths. "
|
||||
+ "You can access various parts of the path using the following modifiers:"
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write("{path.parent}: the parent directory\n")
|
||||
formatter.write("{path.name}: the name of the file or final sub-directory\n")
|
||||
formatter.write("{path.stem}: the name of the file without the extension\n")
|
||||
formatter.write(
|
||||
"{path.suffix}: the suffix of the file including the leading '.'\n"
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
"For example, if the field {export_dir} is '/Shared/Backup/Photos':\n"
|
||||
)
|
||||
formatter.write("{export_dir.parent} is '/Shared/Backup'\n")
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
"If the field {filepath} is '/Shared/Backup/Photos/IMG_1234.JPG':\n"
|
||||
)
|
||||
formatter.write("{filepath.parent} is '/Shared/Backup/Photos'\n")
|
||||
formatter.write("{filepath.name} is 'IMG_1234.JPG'\n")
|
||||
formatter.write("{filepath.stem} is 'IMG_1234'\n")
|
||||
formatter.write("{filepath.suffix} is '.JPG'\n")
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Substitution", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in TEMPLATE_SUBSTITUTIONS_PATHLIB.items())
|
||||
|
||||
formatter.write_dl(templ_tuples)
|
||||
|
||||
formatter.write("\n\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Post Command **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write_text(
|
||||
"You can run commands on the exported photos for post-processing "
|
||||
+ "using the '--post-command' option. '--post-command' is passed a CATEGORY and a COMMAND. "
|
||||
+ "COMMAND is an osxphotos template string which will be rendered and passed to the shell "
|
||||
+ "for execution. CATEGORY is the category of file to pass to COMMAND. "
|
||||
+ "The following categories are available: "
|
||||
)
|
||||
formatter.write("\n")
|
||||
templ_tuples = [("Category", "Description")]
|
||||
templ_tuples.extend((k, v) for k, v in POST_COMMAND_CATEGORIES.items())
|
||||
formatter.write_dl(templ_tuples)
|
||||
formatter.write("\n")
|
||||
formatter.write_text(
|
||||
"In addition to all normal template fields, the template fields "
|
||||
+ "'{filepath}' and '{export_dir}' will be available to your command template. "
|
||||
+ "Both of these are path-type templates which means their various parts can be accessed using "
|
||||
+ "the available properties, e.g. '{filepath.name}' provides just the file name without path "
|
||||
+ "and '{filepath.suffix}' is the file extension (suffix) of the file. "
|
||||
+ "When using paths in your command template, it is important to properly quote the paths "
|
||||
+ "as they will be passed to the shell and path names may contain spaces. "
|
||||
+ "Both the '{shell_quote}' template and the '|shell_quote' template filter are available for "
|
||||
+ "this purpose. For example, the following command outputs the full path of newly exported files to file 'new.txt': "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write(
|
||||
'--post-command new "echo {filepath|shell_quote} >> {shell_quote,{export_dir}/exported.txt}"'
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write_text(
|
||||
"In the above command, the 'shell_quote' filter is used to ensure '{filepath}' is properly quoted "
|
||||
+ "and the '{shell_quote}' template ensures the constructed path of '{exported_dir}/exported.txt' is properly quoted. "
|
||||
"If '{filepath}' is 'IMG 1234.jpeg' and '{export_dir}' is '/Volumes/Photo Export', the command "
|
||||
"thus renders to: "
|
||||
)
|
||||
formatter.write("\n")
|
||||
formatter.write("echo 'IMG 1234.jpeg' >> '/Volumes/Photo Export/exported.txt'")
|
||||
formatter.write("\n\n")
|
||||
formatter.write_text(
|
||||
"It is highly recommended that you run osxphotos with '--dry-run --verbose' "
|
||||
+ "first to ensure your commands are as expected. This will not actually run the commands but will "
|
||||
+ "print out the exact command string which would be executed."
|
||||
)
|
||||
formatter.write("\n\n")
|
||||
formatter.write(
|
||||
rich_text("[bold]** Post Function **[/bold]", width=formatter.width)
|
||||
)
|
||||
formatter.write_text(
|
||||
"You can run your own python functions on the exported photos for post-processing "
|
||||
+ "using the '--post-function' option. '--post-function' is passed the name a python file "
|
||||
+ "and the name of the function in the file to call using format 'filename.py::function_name'. "
|
||||
+ "See the example function at https://github.com/RhetTbull/osxphotos/blob/master/examples/post_function.py "
|
||||
+ "You may specify multiple functions to run by repeating the --post-function option. "
|
||||
+ "All post functions will be called immediately after export of each photo and immediately before any --post-command commands. "
|
||||
+ "Post functions will not be called if the --dry-run flag is set."
|
||||
)
|
||||
formatter.write("\n")
|
||||
|
||||
help_text += formatter.getvalue()
|
||||
return help_text
|
||||
|
||||
|
||||
def template_help(width=78):
|
||||
"""Return formatted string for template system """
|
||||
"""Return formatted string for template system"""
|
||||
sio = io.StringIO()
|
||||
console = Console(file=sio, force_terminal=True, width=width)
|
||||
template_help_md = strip_md_links(get_template_help())
|
||||
template_help_md = strip_md_header_and_links(get_template_help())
|
||||
console.print(Markdown(template_help_md))
|
||||
help_str = sio.getvalue()
|
||||
sio.close()
|
||||
@@ -176,16 +313,16 @@ def rich_text(text, width=78):
|
||||
return rich_text
|
||||
|
||||
|
||||
def strip_md_links(md):
|
||||
"""strip markdown links from markdown text md
|
||||
|
||||
def strip_md_header_and_links(md):
|
||||
"""strip markdown headers and links from markdown text md
|
||||
|
||||
Args:
|
||||
md: str, markdown text
|
||||
|
||||
Returns:
|
||||
str with markdown links removed
|
||||
|
||||
Note: This uses a very basic regex that likely fails on all sorts of edge cases
|
||||
Returns:
|
||||
str with markdown headers and links removed
|
||||
|
||||
Note: This uses a very basic regex that likely fails on all sorts of edge cases
|
||||
but works for the links in the osxphotos docs
|
||||
"""
|
||||
links = r"(?:[*#])|\[(.*?)\]\(.+?\)"
|
||||
@@ -195,3 +332,27 @@ def strip_md_links(md):
|
||||
|
||||
return re.sub(links, subfn, md)
|
||||
|
||||
|
||||
def strip_md_links(md):
|
||||
"""strip markdown links from markdown text md
|
||||
|
||||
Args:
|
||||
md: str, markdown text
|
||||
|
||||
Returns:
|
||||
str with markdown links removed
|
||||
|
||||
Note: This uses a very basic regex that likely fails on all sorts of edge cases
|
||||
but works for the links in the osxphotos docs
|
||||
"""
|
||||
links = r"\[(.*?)\]\(.+?\)"
|
||||
|
||||
def subfn(match):
|
||||
return match.group(1)
|
||||
|
||||
return re.sub(links, subfn, md)
|
||||
|
||||
|
||||
def strip_html_comments(text):
|
||||
"""Strip html comments from text (which doesn't need to be valid HTML)"""
|
||||
return re.sub(r"<!--(.|\s|\n)*?-->", "", text)
|
||||
72
osxphotos/cli/info.py
Normal file
72
osxphotos/cli/info.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""info command for osxphotos CLI"""
|
||||
|
||||
import json
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _PHOTOS_4_VERSION
|
||||
|
||||
from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db
|
||||
from .list import _list_libraries
|
||||
|
||||
|
||||
@click.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def info(ctx, cli_obj, db, json_, photos_library):
|
||||
"""Print out descriptive info of the Photos library database."""
|
||||
|
||||
db = get_photos_db(*photos_library, db, cli_obj.db)
|
||||
if db is None:
|
||||
click.echo(ctx.obj.group.commands["info"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
info = {"database_path": photosdb.db_path, "database_version": photosdb.db_version}
|
||||
photos = photosdb.photos(movies=False)
|
||||
not_shared_photos = [p for p in photos if not p.shared]
|
||||
info["photo_count"] = len(not_shared_photos)
|
||||
|
||||
hidden = [p for p in photos if p.hidden]
|
||||
info["hidden_photo_count"] = len(hidden)
|
||||
|
||||
movies = photosdb.photos(images=False, movies=True)
|
||||
not_shared_movies = [p for p in movies if not p.shared]
|
||||
info["movie_count"] = len(not_shared_movies)
|
||||
|
||||
if photosdb.db_version > _PHOTOS_4_VERSION:
|
||||
shared_photos = [p for p in photos if p.shared]
|
||||
info["shared_photo_count"] = len(shared_photos)
|
||||
|
||||
shared_movies = [p for p in movies if p.shared]
|
||||
info["shared_movie_count"] = len(shared_movies)
|
||||
|
||||
keywords = photosdb.keywords_as_dict
|
||||
info["keywords_count"] = len(keywords)
|
||||
info["keywords"] = keywords
|
||||
|
||||
albums = photosdb.albums_as_dict
|
||||
info["albums_count"] = len(albums)
|
||||
info["albums"] = albums
|
||||
|
||||
if photosdb.db_version > _PHOTOS_4_VERSION:
|
||||
albums_shared = photosdb.albums_shared_as_dict
|
||||
info["shared_albums_count"] = len(albums_shared)
|
||||
info["shared_albums"] = albums_shared
|
||||
|
||||
persons = photosdb.persons_as_dict
|
||||
|
||||
info["persons_count"] = len(persons)
|
||||
info["persons"] = persons
|
||||
|
||||
if cli_obj.json or json_:
|
||||
click.echo(json.dumps(info, ensure_ascii=False))
|
||||
else:
|
||||
click.echo(yaml.dump(info, sort_keys=False, allow_unicode=True))
|
||||
37
osxphotos/cli/install_uninstall_run.py
Normal file
37
osxphotos/cli/install_uninstall_run.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""install/uninstall/run commands for osxphotos CLI"""
|
||||
|
||||
import sys
|
||||
from runpy import run_module, run_path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("packages", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version"
|
||||
)
|
||||
def install(packages, upgrade):
|
||||
"""Install Python packages into the same environment as osxphotos"""
|
||||
args = ["pip", "install"]
|
||||
if upgrade:
|
||||
args += ["--upgrade"]
|
||||
args += list(packages)
|
||||
sys.argv = args
|
||||
run_module("pip", run_name="__main__")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("packages", nargs=-1, required=True)
|
||||
@click.option("-y", "--yes", is_flag=True, help="Don't ask for confirmation")
|
||||
def uninstall(packages, yes):
|
||||
"""Uninstall Python packages from the osxphotos environment"""
|
||||
sys.argv = ["pip", "uninstall"] + list(packages) + (["-y"] if yes else [])
|
||||
run_module("pip", run_name="__main__")
|
||||
|
||||
|
||||
@click.command(name="run")
|
||||
@click.argument("python_file", nargs=1, type=click.Path(exists=True))
|
||||
def run(python_file):
|
||||
"""Run a python file using same environment as osxphotos"""
|
||||
run_path(python_file, run_name="__main__")
|
||||
37
osxphotos/cli/keywords.py
Normal file
37
osxphotos/cli/keywords.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""keywords command for osxphotos CLI"""
|
||||
|
||||
import json
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
import osxphotos
|
||||
|
||||
from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db
|
||||
from .list import _list_libraries
|
||||
|
||||
|
||||
@click.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def keywords(ctx, cli_obj, db, json_, photos_library):
|
||||
"""Print out keywords found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(ctx.obj.group.commands["keywords"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
keywords = {"keywords": photosdb.keywords_as_dict}
|
||||
if json_ or cli_obj.json:
|
||||
click.echo(json.dumps(keywords, ensure_ascii=False))
|
||||
else:
|
||||
click.echo(yaml.dump(keywords, sort_keys=False, allow_unicode=True))
|
||||
37
osxphotos/cli/labels.py
Normal file
37
osxphotos/cli/labels.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""labels command for osxphotos CLI"""
|
||||
|
||||
import json
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
import osxphotos
|
||||
|
||||
from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db
|
||||
from .list import _list_libraries
|
||||
|
||||
|
||||
@click.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def labels(ctx, cli_obj, db, json_, photos_library):
|
||||
"""Print out image classification labels found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(ctx.obj.group.commands["labels"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
labels = {"labels": photosdb.labels_as_dict}
|
||||
if json_ or cli_obj.json:
|
||||
click.echo(json.dumps(labels, ensure_ascii=False))
|
||||
else:
|
||||
click.echo(yaml.dump(labels, sort_keys=False, allow_unicode=True))
|
||||
57
osxphotos/cli/list.py
Normal file
57
osxphotos/cli/list.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""list command for osxphotos CLI"""
|
||||
|
||||
import json
|
||||
|
||||
import click
|
||||
|
||||
import osxphotos
|
||||
|
||||
from .common import JSON_OPTION
|
||||
|
||||
|
||||
@click.command(name="list")
|
||||
@JSON_OPTION
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def list_libraries(ctx, cli_obj, json_):
|
||||
"""Print list of Photos libraries found on the system."""
|
||||
|
||||
# implemented in _list_libraries so it can be called by other CLI functions
|
||||
# without errors due to passing ctx and cli_obj
|
||||
_list_libraries(json_=json_ or cli_obj.json, error=False)
|
||||
|
||||
|
||||
def _list_libraries(json_=False, error=True):
|
||||
"""Print list of Photos libraries found on the system.
|
||||
If json_ == True, print output as JSON (default = False)"""
|
||||
|
||||
photo_libs = osxphotos.utils.list_photo_libraries()
|
||||
sys_lib = osxphotos.utils.get_system_library_path()
|
||||
last_lib = osxphotos.utils.get_last_library_path()
|
||||
|
||||
if json_:
|
||||
libs = {
|
||||
"photo_libraries": photo_libs,
|
||||
"system_library": sys_lib,
|
||||
"last_library": last_lib,
|
||||
}
|
||||
click.echo(json.dumps(libs, ensure_ascii=False))
|
||||
else:
|
||||
last_lib_flag = sys_lib_flag = False
|
||||
|
||||
for lib in photo_libs:
|
||||
if lib == sys_lib:
|
||||
click.echo(f"(*)\t{lib}", err=error)
|
||||
sys_lib_flag = True
|
||||
elif lib == last_lib:
|
||||
click.echo(f"(#)\t{lib}", err=error)
|
||||
last_lib_flag = True
|
||||
else:
|
||||
click.echo(f"\t{lib}", err=error)
|
||||
|
||||
if sys_lib_flag or last_lib_flag:
|
||||
click.echo("\n", err=error)
|
||||
if sys_lib_flag:
|
||||
click.echo("(*)\tSystem Photos Library", err=error)
|
||||
if last_lib_flag:
|
||||
click.echo("(#)\tLast opened Photos Library", err=error)
|
||||
108
osxphotos/cli/param_types.py
Normal file
108
osxphotos/cli/param_types.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Click parameter types for osxphotos CLI"""
|
||||
import datetime
|
||||
import pathlib
|
||||
|
||||
import bitmath
|
||||
import click
|
||||
|
||||
from osxphotos.export_db_utils import export_db_get_version
|
||||
from osxphotos.utils import expand_and_validate_filepath, load_function
|
||||
|
||||
__all__ = [
|
||||
"BitMathSize",
|
||||
"DateTimeISO8601",
|
||||
"ExportDBType",
|
||||
"FunctionCall",
|
||||
"TimeISO8601",
|
||||
]
|
||||
|
||||
|
||||
class DateTimeISO8601(click.ParamType):
|
||||
|
||||
name = "DATETIME"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(value)
|
||||
except Exception:
|
||||
self.fail(
|
||||
f"Invalid datetime format {value}. "
|
||||
"Valid format: YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]"
|
||||
)
|
||||
|
||||
|
||||
class BitMathSize(click.ParamType):
|
||||
|
||||
name = "BITMATH"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
try:
|
||||
value = bitmath.parse_string(value)
|
||||
except ValueError:
|
||||
# no units specified
|
||||
try:
|
||||
value = int(value)
|
||||
value = bitmath.Byte(value)
|
||||
except ValueError as e:
|
||||
self.fail(
|
||||
f"{value} must be specified as bytes or using SI/NIST units. "
|
||||
+ "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class TimeISO8601(click.ParamType):
|
||||
|
||||
name = "TIME"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
try:
|
||||
return datetime.time.fromisoformat(value).replace(tzinfo=None)
|
||||
except Exception:
|
||||
self.fail(
|
||||
f"Invalid time format {value}. "
|
||||
"Valid format: HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]] "
|
||||
"however, note that timezone will be ignored."
|
||||
)
|
||||
|
||||
|
||||
class FunctionCall(click.ParamType):
|
||||
name = "FUNCTION"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
if "::" not in value:
|
||||
self.fail(
|
||||
f"Could not parse function name from '{value}'. "
|
||||
"Valid format filename.py::function"
|
||||
)
|
||||
|
||||
filename, funcname = value.split("::")
|
||||
|
||||
filename_validated = expand_and_validate_filepath(filename)
|
||||
if not filename_validated:
|
||||
self.fail(f"'{filename}' does not appear to be a file")
|
||||
|
||||
try:
|
||||
function = load_function(filename_validated, funcname)
|
||||
except Exception as e:
|
||||
self.fail(f"Could not load function {funcname} from {filename_validated}")
|
||||
|
||||
return (function, value)
|
||||
|
||||
|
||||
class ExportDBType(click.ParamType):
|
||||
|
||||
name = "EXPORTDB"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
try:
|
||||
export_db_name = pathlib.Path(value)
|
||||
if export_db_name.is_dir():
|
||||
raise click.BadParameter(f"{value} is a directory")
|
||||
if export_db_name.is_file():
|
||||
# verify it's actually an osxphotos export_db
|
||||
# export_db_get_version will raise an error if it's not valid
|
||||
osxphotos_ver, export_db_ver = export_db_get_version(value)
|
||||
return value
|
||||
except Exception:
|
||||
self.fail(f"{value} exists but is not a valid osxphotos export database. ")
|
||||
36
osxphotos/cli/persons.py
Normal file
36
osxphotos/cli/persons.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""persons command for osxphotos CLI"""
|
||||
import json
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
import osxphotos
|
||||
|
||||
from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db
|
||||
from .list import _list_libraries
|
||||
|
||||
|
||||
@click.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def persons(ctx, cli_obj, db, json_, photos_library):
|
||||
"""Print out persons (faces) found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(ctx.obj.group.commands["persons"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
persons = {"persons": photosdb.persons_as_dict}
|
||||
if json_ or cli_obj.json:
|
||||
click.echo(json.dumps(persons, ensure_ascii=False))
|
||||
else:
|
||||
click.echo(yaml.dump(persons, sort_keys=False, allow_unicode=True))
|
||||
62
osxphotos/cli/places.py
Normal file
62
osxphotos/cli/places.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""places command for osxphotos CLI"""
|
||||
|
||||
import json
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _PHOTOS_4_VERSION, _UNKNOWN_PLACE
|
||||
|
||||
from .common import DB_ARGUMENT, DB_OPTION, JSON_OPTION, get_photos_db
|
||||
from .list import _list_libraries
|
||||
|
||||
|
||||
@click.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def places(ctx, cli_obj, db, json_, photos_library):
|
||||
"""Print out places found in the Photos library."""
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(ctx.obj.group.commands["places"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
place_names = {}
|
||||
for photo in photosdb.photos(movies=True):
|
||||
if photo.place:
|
||||
try:
|
||||
place_names[photo.place.name] += 1
|
||||
except Exception:
|
||||
place_names[photo.place.name] = 1
|
||||
else:
|
||||
try:
|
||||
place_names[_UNKNOWN_PLACE] += 1
|
||||
except Exception:
|
||||
place_names[_UNKNOWN_PLACE] = 1
|
||||
|
||||
# sort by place count
|
||||
places = {
|
||||
"places": {
|
||||
name: place_names[name]
|
||||
for name in sorted(
|
||||
place_names.keys(), key=lambda key: place_names[key], reverse=True
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_json = cli_obj.json if cli_obj is not None else None
|
||||
if json_ or cli_json:
|
||||
click.echo(json.dumps(places, ensure_ascii=False))
|
||||
else:
|
||||
click.echo(yaml.dump(places, sort_keys=False, allow_unicode=True))
|
||||
112
osxphotos/cli/print_photo_info.py
Normal file
112
osxphotos/cli/print_photo_info.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""print_photo_info function to print PhotoInfo objects"""
|
||||
|
||||
import csv
|
||||
import sys
|
||||
from typing import Callable, List
|
||||
|
||||
from osxphotos.photoinfo import PhotoInfo
|
||||
|
||||
|
||||
def print_photo_info(
|
||||
photos: List[PhotoInfo], json: bool = False, print_func: Callable = print
|
||||
):
|
||||
dump = []
|
||||
if json:
|
||||
dump.extend(p.json() for p in photos)
|
||||
print_func(f"[{', '.join(dump)}]")
|
||||
else:
|
||||
# dump as CSV
|
||||
csv_writer = csv.writer(
|
||||
sys.stdout, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL
|
||||
)
|
||||
# add headers
|
||||
dump.append(
|
||||
[
|
||||
"uuid",
|
||||
"filename",
|
||||
"original_filename",
|
||||
"date",
|
||||
"description",
|
||||
"title",
|
||||
"keywords",
|
||||
"albums",
|
||||
"persons",
|
||||
"path",
|
||||
"ismissing",
|
||||
"hasadjustments",
|
||||
"external_edit",
|
||||
"favorite",
|
||||
"hidden",
|
||||
"shared",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"path_edited",
|
||||
"isphoto",
|
||||
"ismovie",
|
||||
"uti",
|
||||
"burst",
|
||||
"live_photo",
|
||||
"path_live_photo",
|
||||
"iscloudasset",
|
||||
"incloud",
|
||||
"date_modified",
|
||||
"portrait",
|
||||
"screenshot",
|
||||
"slow_mo",
|
||||
"time_lapse",
|
||||
"hdr",
|
||||
"selfie",
|
||||
"panorama",
|
||||
"has_raw",
|
||||
"uti_raw",
|
||||
"path_raw",
|
||||
"intrash",
|
||||
]
|
||||
)
|
||||
for p in photos:
|
||||
date_modified_iso = p.date_modified.isoformat() if p.date_modified else None
|
||||
dump.append(
|
||||
[
|
||||
p.uuid,
|
||||
p.filename,
|
||||
p.original_filename,
|
||||
p.date.isoformat(),
|
||||
p.description,
|
||||
p.title,
|
||||
", ".join(p.keywords),
|
||||
", ".join(p.albums),
|
||||
", ".join(p.persons),
|
||||
p.path,
|
||||
p.ismissing,
|
||||
p.hasadjustments,
|
||||
p.external_edit,
|
||||
p.favorite,
|
||||
p.hidden,
|
||||
p.shared,
|
||||
p._latitude,
|
||||
p._longitude,
|
||||
p.path_edited,
|
||||
p.isphoto,
|
||||
p.ismovie,
|
||||
p.uti,
|
||||
p.burst,
|
||||
p.live_photo,
|
||||
p.path_live_photo,
|
||||
p.iscloudasset,
|
||||
p.incloud,
|
||||
date_modified_iso,
|
||||
p.portrait,
|
||||
p.screenshot,
|
||||
p.slow_mo,
|
||||
p.time_lapse,
|
||||
p.hdr,
|
||||
p.selfie,
|
||||
p.panorama,
|
||||
p.has_raw,
|
||||
p.uti_raw,
|
||||
p.path_raw,
|
||||
p.intrash,
|
||||
]
|
||||
)
|
||||
for row in dump:
|
||||
csv_writer.writerow(row)
|
||||
358
osxphotos/cli/query.py
Normal file
358
osxphotos/cli/query.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""query command for osxphotos CLI"""
|
||||
|
||||
import click
|
||||
|
||||
import osxphotos
|
||||
from osxphotos.photosalbum import PhotosAlbum
|
||||
from osxphotos.queryoptions import QueryOptions
|
||||
|
||||
from .common import (
|
||||
CLI_COLOR_ERROR,
|
||||
CLI_COLOR_WARNING,
|
||||
DB_ARGUMENT,
|
||||
DB_OPTION,
|
||||
DELETED_OPTIONS,
|
||||
JSON_OPTION,
|
||||
OSXPHOTOS_HIDDEN,
|
||||
QUERY_OPTIONS,
|
||||
get_photos_db,
|
||||
load_uuid_from_file,
|
||||
set_debug,
|
||||
)
|
||||
from .list import _list_libraries
|
||||
from .print_photo_info import print_photo_info
|
||||
|
||||
|
||||
@click.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@QUERY_OPTIONS
|
||||
@DELETED_OPTIONS
|
||||
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
|
||||
@click.option(
|
||||
"--not-missing",
|
||||
is_flag=True,
|
||||
help="Search for photos present on disk (e.g. not missing).",
|
||||
)
|
||||
@click.option(
|
||||
"--cloudasset",
|
||||
is_flag=True,
|
||||
help="Search for photos that are part of an iCloud library",
|
||||
)
|
||||
@click.option(
|
||||
"--not-cloudasset",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not part of an iCloud library",
|
||||
)
|
||||
@click.option(
|
||||
"--incloud",
|
||||
is_flag=True,
|
||||
help="Search for photos that are in iCloud (have been synched)",
|
||||
)
|
||||
@click.option(
|
||||
"--not-incloud",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not in iCloud (have not been synched)",
|
||||
)
|
||||
@click.option(
|
||||
"--add-to-album",
|
||||
metavar="ALBUM",
|
||||
help="Add all photos from query to album ALBUM in Photos. Album ALBUM will be created "
|
||||
"if it doesn't exist. All photos in the query results will be added to this album. "
|
||||
"This only works if the Photos library being queried is the last-opened (default) library in Photos. "
|
||||
"This feature is currently experimental. I don't know how well it will work on large query sets.",
|
||||
)
|
||||
@click.option(
|
||||
"--debug", required=False, is_flag=True, default=False, hidden=OSXPHOTOS_HIDDEN
|
||||
)
|
||||
@DB_ARGUMENT
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def query(
|
||||
ctx,
|
||||
cli_obj,
|
||||
db,
|
||||
photos_library,
|
||||
keyword,
|
||||
person,
|
||||
album,
|
||||
folder,
|
||||
name,
|
||||
uuid,
|
||||
uuid_from_file,
|
||||
title,
|
||||
no_title,
|
||||
description,
|
||||
no_description,
|
||||
ignore_case,
|
||||
json_,
|
||||
edited,
|
||||
external_edit,
|
||||
favorite,
|
||||
not_favorite,
|
||||
hidden,
|
||||
not_hidden,
|
||||
missing,
|
||||
not_missing,
|
||||
shared,
|
||||
not_shared,
|
||||
only_movies,
|
||||
only_photos,
|
||||
uti,
|
||||
burst,
|
||||
not_burst,
|
||||
live,
|
||||
not_live,
|
||||
cloudasset,
|
||||
not_cloudasset,
|
||||
incloud,
|
||||
not_incloud,
|
||||
from_date,
|
||||
to_date,
|
||||
from_time,
|
||||
to_time,
|
||||
portrait,
|
||||
not_portrait,
|
||||
screenshot,
|
||||
not_screenshot,
|
||||
slow_mo,
|
||||
not_slow_mo,
|
||||
time_lapse,
|
||||
not_time_lapse,
|
||||
hdr,
|
||||
not_hdr,
|
||||
selfie,
|
||||
not_selfie,
|
||||
panorama,
|
||||
not_panorama,
|
||||
has_raw,
|
||||
place,
|
||||
no_place,
|
||||
location,
|
||||
no_location,
|
||||
label,
|
||||
deleted,
|
||||
deleted_only,
|
||||
has_comment,
|
||||
no_comment,
|
||||
has_likes,
|
||||
no_likes,
|
||||
is_reference,
|
||||
in_album,
|
||||
not_in_album,
|
||||
duplicate,
|
||||
min_size,
|
||||
max_size,
|
||||
regex,
|
||||
selected,
|
||||
exif,
|
||||
query_eval,
|
||||
query_function,
|
||||
add_to_album,
|
||||
debug,
|
||||
):
|
||||
"""Query the Photos database using 1 or more search options;
|
||||
if more than one option is provided, they are treated as "AND"
|
||||
(e.g. search for photos matching all options).
|
||||
"""
|
||||
|
||||
if debug:
|
||||
set_debug(True)
|
||||
osxphotos._set_debug(True)
|
||||
|
||||
# if no query terms, show help and return
|
||||
# sanity check input args
|
||||
nonexclusive = [
|
||||
keyword,
|
||||
person,
|
||||
album,
|
||||
folder,
|
||||
name,
|
||||
uuid,
|
||||
uuid_from_file,
|
||||
edited,
|
||||
external_edit,
|
||||
uti,
|
||||
has_raw,
|
||||
from_date,
|
||||
to_date,
|
||||
from_time,
|
||||
to_time,
|
||||
label,
|
||||
is_reference,
|
||||
query_eval,
|
||||
query_function,
|
||||
min_size,
|
||||
max_size,
|
||||
regex,
|
||||
selected,
|
||||
exif,
|
||||
duplicate,
|
||||
]
|
||||
exclusive = [
|
||||
(favorite, not_favorite),
|
||||
(hidden, not_hidden),
|
||||
(missing, not_missing),
|
||||
(any(title), no_title),
|
||||
(any(description), no_description),
|
||||
(only_photos, only_movies),
|
||||
(burst, not_burst),
|
||||
(live, not_live),
|
||||
(cloudasset, not_cloudasset),
|
||||
(incloud, not_incloud),
|
||||
(portrait, not_portrait),
|
||||
(screenshot, not_screenshot),
|
||||
(slow_mo, not_slow_mo),
|
||||
(time_lapse, not_time_lapse),
|
||||
(hdr, not_hdr),
|
||||
(selfie, not_selfie),
|
||||
(panorama, not_panorama),
|
||||
(any(place), no_place),
|
||||
(deleted, deleted_only),
|
||||
(shared, not_shared),
|
||||
(has_comment, no_comment),
|
||||
(has_likes, no_likes),
|
||||
(in_album, not_in_album),
|
||||
(location, no_location),
|
||||
]
|
||||
# print help if no non-exclusive term or a double exclusive term is given
|
||||
if any(all(bb) for bb in exclusive) or not any(
|
||||
nonexclusive + [b ^ n for b, n in exclusive]
|
||||
):
|
||||
click.echo("Incompatible query options", err=True)
|
||||
click.echo(ctx.obj.group.commands["query"].get_help(ctx), err=True)
|
||||
return
|
||||
|
||||
# actually have something to query
|
||||
# default searches for everything
|
||||
photos = True
|
||||
movies = True
|
||||
if only_movies:
|
||||
photos = False
|
||||
if only_photos:
|
||||
movies = False
|
||||
|
||||
# load UUIDs if necessary and append to any uuids passed with --uuid
|
||||
if uuid_from_file:
|
||||
uuid_list = list(uuid) # Click option is a tuple
|
||||
uuid_list.extend(load_uuid_from_file(uuid_from_file))
|
||||
uuid = tuple(uuid_list)
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_db = cli_obj.db if cli_obj is not None else None
|
||||
db = get_photos_db(*photos_library, db, cli_db)
|
||||
if db is None:
|
||||
click.echo(ctx.obj.group.commands["query"].get_help(ctx), err=True)
|
||||
click.echo("\n\nLocated the following Photos library databases: ", err=True)
|
||||
_list_libraries()
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db)
|
||||
query_options = QueryOptions(
|
||||
keyword=keyword,
|
||||
person=person,
|
||||
album=album,
|
||||
folder=folder,
|
||||
uuid=uuid,
|
||||
title=title,
|
||||
no_title=no_title,
|
||||
description=description,
|
||||
no_description=no_description,
|
||||
ignore_case=ignore_case,
|
||||
edited=edited,
|
||||
external_edit=external_edit,
|
||||
favorite=favorite,
|
||||
not_favorite=not_favorite,
|
||||
hidden=hidden,
|
||||
not_hidden=not_hidden,
|
||||
missing=missing,
|
||||
not_missing=not_missing,
|
||||
shared=shared,
|
||||
not_shared=not_shared,
|
||||
photos=photos,
|
||||
movies=movies,
|
||||
uti=uti,
|
||||
burst=burst,
|
||||
not_burst=not_burst,
|
||||
live=live,
|
||||
not_live=not_live,
|
||||
cloudasset=cloudasset,
|
||||
not_cloudasset=not_cloudasset,
|
||||
incloud=incloud,
|
||||
not_incloud=not_incloud,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
portrait=portrait,
|
||||
not_portrait=not_portrait,
|
||||
screenshot=screenshot,
|
||||
not_screenshot=not_screenshot,
|
||||
slow_mo=slow_mo,
|
||||
not_slow_mo=not_slow_mo,
|
||||
time_lapse=time_lapse,
|
||||
not_time_lapse=not_time_lapse,
|
||||
hdr=hdr,
|
||||
not_hdr=not_hdr,
|
||||
selfie=selfie,
|
||||
not_selfie=not_selfie,
|
||||
panorama=panorama,
|
||||
not_panorama=not_panorama,
|
||||
has_raw=has_raw,
|
||||
place=place,
|
||||
no_place=no_place,
|
||||
location=location,
|
||||
no_location=no_location,
|
||||
label=label,
|
||||
deleted=deleted,
|
||||
deleted_only=deleted_only,
|
||||
has_comment=has_comment,
|
||||
no_comment=no_comment,
|
||||
has_likes=has_likes,
|
||||
no_likes=no_likes,
|
||||
is_reference=is_reference,
|
||||
in_album=in_album,
|
||||
not_in_album=not_in_album,
|
||||
name=name,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
query_eval=query_eval,
|
||||
function=query_function,
|
||||
regex=regex,
|
||||
selected=selected,
|
||||
exif=exif,
|
||||
duplicate=duplicate,
|
||||
)
|
||||
|
||||
try:
|
||||
photos = photosdb.query(query_options)
|
||||
except ValueError as e:
|
||||
if "Invalid query_eval CRITERIA:" in str(e):
|
||||
msg = str(e).split(":")[1]
|
||||
raise click.BadOptionUsage(
|
||||
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(e)
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_json = cli_obj.json if cli_obj is not None else None
|
||||
|
||||
if add_to_album and photos:
|
||||
album_query = PhotosAlbum(add_to_album, verbose=None)
|
||||
photo_len = len(photos)
|
||||
photo_word = "photos" if photo_len > 1 else "photo"
|
||||
click.echo(
|
||||
f"Adding {photo_len} {photo_word} to album '{album_query.name}'. Note: Photos may prompt you to confirm this action.",
|
||||
err=True,
|
||||
)
|
||||
try:
|
||||
album_query.add_list(photos)
|
||||
except Exception as e:
|
||||
click.secho(
|
||||
f"Error adding photos to album {add_to_album}: {e}",
|
||||
fg=CLI_COLOR_ERROR,
|
||||
err=True,
|
||||
)
|
||||
|
||||
print_photo_info(photos, cli_json or json_, print_func=click.echo)
|
||||
339
osxphotos/cli/repl.py
Normal file
339
osxphotos/cli/repl.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""repl command for osxphotos CLI"""
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import click
|
||||
import photoscript
|
||||
from rich import pretty, print
|
||||
|
||||
import osxphotos
|
||||
from osxphotos._constants import _PHOTOS_4_VERSION
|
||||
from osxphotos.photoinfo import PhotoInfo
|
||||
from osxphotos.photosdb import PhotosDB
|
||||
from osxphotos.pyrepl import embed_repl
|
||||
from osxphotos.queryoptions import QueryOptions
|
||||
|
||||
from .common import (
|
||||
DB_ARGUMENT,
|
||||
DB_OPTION,
|
||||
DELETED_OPTIONS,
|
||||
QUERY_OPTIONS,
|
||||
get_photos_db,
|
||||
load_uuid_from_file,
|
||||
)
|
||||
|
||||
|
||||
class IncompatibleQueryOptions(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@click.command(name="repl")
|
||||
@DB_OPTION
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--emacs",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Launch REPL with Emacs keybindings (default is vi bindings)",
|
||||
)
|
||||
@click.option(
|
||||
"--beta",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
hidden=True,
|
||||
help="Enable beta options.",
|
||||
)
|
||||
@QUERY_OPTIONS
|
||||
@DELETED_OPTIONS
|
||||
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
|
||||
@click.option(
|
||||
"--not-missing",
|
||||
is_flag=True,
|
||||
help="Search for photos present on disk (e.g. not missing).",
|
||||
)
|
||||
@click.option(
|
||||
"--cloudasset",
|
||||
is_flag=True,
|
||||
help="Search for photos that are part of an iCloud library",
|
||||
)
|
||||
@click.option(
|
||||
"--not-cloudasset",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not part of an iCloud library",
|
||||
)
|
||||
@click.option(
|
||||
"--incloud",
|
||||
is_flag=True,
|
||||
help="Search for photos that are in iCloud (have been synched)",
|
||||
)
|
||||
@click.option(
|
||||
"--not-incloud",
|
||||
is_flag=True,
|
||||
help="Search for photos that are not in iCloud (have not been synched)",
|
||||
)
|
||||
def repl(ctx, cli_obj, db, emacs, beta, **kwargs):
|
||||
"""Run interactive osxphotos REPL shell (useful for debugging, prototyping, and inspecting your Photos library)"""
|
||||
import logging
|
||||
|
||||
from objexplore import explore
|
||||
from photoscript import Album, Photo, PhotosLibrary
|
||||
from rich import inspect as _inspect
|
||||
|
||||
from osxphotos import ExifTool, PhotoInfo, PhotosDB
|
||||
from osxphotos.albuminfo import AlbumInfo
|
||||
from osxphotos.momentinfo import MomentInfo
|
||||
from osxphotos.photoexporter import ExportOptions, ExportResults, PhotoExporter
|
||||
from osxphotos.placeinfo import PlaceInfo
|
||||
from osxphotos.queryoptions import QueryOptions
|
||||
from osxphotos.scoreinfo import ScoreInfo
|
||||
from osxphotos.searchinfo import SearchInfo
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.disabled = True
|
||||
|
||||
pretty.install()
|
||||
print(f"python version: {sys.version}")
|
||||
print(f"osxphotos version: {osxphotos._version.__version__}")
|
||||
db = db or get_photos_db()
|
||||
photosdb = _load_photos_db(db)
|
||||
# enable beta features if requested
|
||||
if beta:
|
||||
photosdb._beta = beta
|
||||
print("Beta mode enabled")
|
||||
print("Getting photos")
|
||||
tic = time.perf_counter()
|
||||
try:
|
||||
query_options = _query_options_from_kwargs(**kwargs)
|
||||
except IncompatibleQueryOptions:
|
||||
click.echo("Incompatible query options", err=True)
|
||||
click.echo(ctx.obj.group.commands["repl"].get_help(ctx), err=True)
|
||||
sys.exit(1)
|
||||
photos = _query_photos(photosdb, query_options)
|
||||
all_photos = _get_all_photos(photosdb)
|
||||
toc = time.perf_counter()
|
||||
tictoc = toc - tic
|
||||
|
||||
# shortcut for helper functions
|
||||
get_photo = photosdb.get_photo
|
||||
show = _show_photo
|
||||
spotlight = _spotlight_photo
|
||||
get_selected = _get_selected(photosdb)
|
||||
try:
|
||||
selected = get_selected()
|
||||
except Exception:
|
||||
# get_selected sometimes fails
|
||||
selected = []
|
||||
|
||||
def inspect(obj):
|
||||
"""inspect object"""
|
||||
return _inspect(obj, methods=True)
|
||||
|
||||
print(f"Found {len(photos)} photos in {tictoc:0.2f} seconds\n")
|
||||
print("The following classes have been imported from osxphotos:")
|
||||
print(
|
||||
"- AlbumInfo, ExifTool, PhotoInfo, PhotoExporter, ExportOptions, ExportResults, PhotosDB, PlaceInfo, QueryOptions, MomentInfo, ScoreInfo, SearchInfo\n"
|
||||
)
|
||||
print("The following variables are defined:")
|
||||
print(f"- photosdb: PhotosDB() instance for {photosdb.library_path}")
|
||||
print(
|
||||
f"- photos: list of PhotoInfo objects for all photos filtered with any query options passed on command line (len={len(photos)})"
|
||||
)
|
||||
print(
|
||||
f"- all_photos: list of PhotoInfo objects for all photos in photosdb, including those in the trash (len={len(all_photos)})"
|
||||
)
|
||||
print(
|
||||
f"- selected: list of PhotoInfo objects for any photos selected in Photos (len={len(selected)})"
|
||||
)
|
||||
print(f"\nThe following functions may be helpful:")
|
||||
print(
|
||||
f"- get_photo(uuid): return a PhotoInfo object for photo with uuid; e.g. get_photo('B13F4485-94E0-41CD-AF71-913095D62E31')"
|
||||
)
|
||||
print(
|
||||
f"- get_selected(); return list of PhotoInfo objects for photos selected in Photos"
|
||||
)
|
||||
print(
|
||||
f"- show(photo): open a photo object in the default viewer; e.g. show(selected[0])"
|
||||
)
|
||||
print(
|
||||
f"- show(path): open a file at path in the default viewer; e.g. show('/path/to/photo.jpg')"
|
||||
)
|
||||
print(f"- spotlight(photo): open a photo and spotlight it in Photos")
|
||||
# print(
|
||||
# f"- help(object): print help text including list of methods for object; for example, help(PhotosDB)"
|
||||
# )
|
||||
print(
|
||||
f"- inspect(object): print information about an object; e.g. inspect(PhotoInfo)"
|
||||
)
|
||||
print(
|
||||
f"- explore(object): interactively explore an object with objexplore; e.g. explore(PhotoInfo)"
|
||||
)
|
||||
print(f"- q, quit, quit(), exit, exit(): exit this interactive shell\n")
|
||||
|
||||
embed_repl(
|
||||
globals=globals(),
|
||||
locals=locals(),
|
||||
history_filename=str(pathlib.Path.home() / ".osxphotos_repl_history"),
|
||||
quit_words=["q", "quit", "exit"],
|
||||
vi_mode=not emacs,
|
||||
)
|
||||
|
||||
|
||||
def _show_photo(photo: PhotoInfo):
|
||||
"""open image with default image viewer
|
||||
|
||||
Note: This is for debugging only -- it will actually open any filetype which could
|
||||
be very, very bad.
|
||||
|
||||
Args:
|
||||
photo: PhotoInfo object or a path to a photo on disk
|
||||
"""
|
||||
photopath = photo.path if isinstance(photo, osxphotos.PhotoInfo) else photo
|
||||
|
||||
if not os.path.isfile(photopath):
|
||||
return f"'{photopath}' does not appear to be a valid photo path"
|
||||
|
||||
os.system(f"open '{photopath}'")
|
||||
|
||||
|
||||
def _load_photos_db(dbpath):
|
||||
print("Loading database")
|
||||
tic = time.perf_counter()
|
||||
photosdb = osxphotos.PhotosDB(dbfile=dbpath, verbose=print)
|
||||
toc = time.perf_counter()
|
||||
tictoc = toc - tic
|
||||
print(f"Done: took {tictoc:0.2f} seconds")
|
||||
return photosdb
|
||||
|
||||
|
||||
def _get_all_photos(photosdb):
|
||||
"""get list of all photos in photosdb"""
|
||||
photos = photosdb.photos(images=True, movies=True)
|
||||
photos.extend(photosdb.photos(images=True, movies=True, intrash=True))
|
||||
return photos
|
||||
|
||||
|
||||
def _get_selected(photosdb):
|
||||
"""get list of PhotoInfo objects for photos selected in Photos"""
|
||||
|
||||
def get_selected():
|
||||
selected = photoscript.PhotosLibrary().selection
|
||||
if not selected:
|
||||
return []
|
||||
return photosdb.photos(uuid=[p.uuid for p in selected])
|
||||
|
||||
return get_selected
|
||||
|
||||
|
||||
def _spotlight_photo(photo: PhotoInfo):
|
||||
photo_ = photoscript.Photo(photo.uuid)
|
||||
photo_.spotlight()
|
||||
|
||||
|
||||
def _query_options_from_kwargs(**kwargs) -> QueryOptions:
|
||||
"""Validate query options and create a QueryOptions instance"""
|
||||
# sanity check input args
|
||||
nonexclusive = [
|
||||
"keyword",
|
||||
"person",
|
||||
"album",
|
||||
"folder",
|
||||
"name",
|
||||
"uuid",
|
||||
"uuid_from_file",
|
||||
"edited",
|
||||
"external_edit",
|
||||
"uti",
|
||||
"has_raw",
|
||||
"from_date",
|
||||
"to_date",
|
||||
"from_time",
|
||||
"to_time",
|
||||
"label",
|
||||
"is_reference",
|
||||
"query_eval",
|
||||
"query_function",
|
||||
"min_size",
|
||||
"max_size",
|
||||
"regex",
|
||||
"selected",
|
||||
"exif",
|
||||
"duplicate",
|
||||
]
|
||||
exclusive = [
|
||||
("favorite", "not_favorite"),
|
||||
("hidden", "not_hidden"),
|
||||
("missing", "not_missing"),
|
||||
("only_photos", "only_movies"),
|
||||
("burst", "not_burst"),
|
||||
("live", "not_live"),
|
||||
("cloudasset", "not_cloudasset"),
|
||||
("incloud", "not_incloud"),
|
||||
("portrait", "not_portrait"),
|
||||
("screenshot", "not_screenshot"),
|
||||
("slow_mo", "not_slow_mo"),
|
||||
("time_lapse", "not_time_lapse"),
|
||||
("hdr", "not_hdr"),
|
||||
("selfie", "not_selfie"),
|
||||
("panorama", "not_panorama"),
|
||||
("deleted", "deleted_only"),
|
||||
("shared", "not_shared"),
|
||||
("has_comment", "no_comment"),
|
||||
("has_likes", "no_likes"),
|
||||
("in_album", "not_in_album"),
|
||||
("location", "no_location"),
|
||||
]
|
||||
# print help if no non-exclusive term or a double exclusive term is given
|
||||
# TODO: add option to validate requiring at least one query arg
|
||||
if any(all([kwargs[b], kwargs[n]]) for b, n in exclusive) or any(
|
||||
[
|
||||
all([any(kwargs["title"]), kwargs["no_title"]]),
|
||||
all([any(kwargs["description"]), kwargs["no_description"]]),
|
||||
all([any(kwargs["place"]), kwargs["no_place"]]),
|
||||
]
|
||||
):
|
||||
raise IncompatibleQueryOptions
|
||||
|
||||
# actually have something to query
|
||||
include_photos = True
|
||||
include_movies = True # default searches for everything
|
||||
if kwargs["only_movies"]:
|
||||
include_photos = False
|
||||
if kwargs["only_photos"]:
|
||||
include_movies = False
|
||||
|
||||
# load UUIDs if necessary and append to any uuids passed with --uuid
|
||||
uuid = None
|
||||
if kwargs["uuid_from_file"]:
|
||||
uuid_list = list(kwargs["uuid"]) # Click option is a tuple
|
||||
uuid_list.extend(load_uuid_from_file(kwargs["uuid_from_file"]))
|
||||
uuid = tuple(uuid_list)
|
||||
|
||||
query_fields = [field.name for field in dataclasses.fields(QueryOptions)]
|
||||
query_dict = {field: kwargs.get(field) for field in query_fields}
|
||||
query_dict["photos"] = include_photos
|
||||
query_dict["movies"] = include_movies
|
||||
query_dict["uuid"] = uuid
|
||||
return QueryOptions(**query_dict)
|
||||
|
||||
|
||||
def _query_photos(photosdb: PhotosDB, query_options: QueryOptions) -> List:
|
||||
"""Query photos given a QueryOptions instance"""
|
||||
try:
|
||||
photos = photosdb.query(query_options)
|
||||
except ValueError as e:
|
||||
if "Invalid query_eval CRITERIA:" not in str(e):
|
||||
raise ValueError(e) from e
|
||||
msg = str(e).split(":")[1]
|
||||
raise click.BadOptionUsage(
|
||||
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
|
||||
) from e
|
||||
|
||||
return photos
|
||||
157
osxphotos/cli/snap_diff.py
Normal file
157
osxphotos/cli/snap_diff.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""snap/diff commands for osxphotos CLI"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.syntax import Syntax
|
||||
|
||||
import osxphotos
|
||||
|
||||
from .common import DB_OPTION, OSXPHOTOS_SNAPSHOT_DIR, get_photos_db, verbose_print
|
||||
|
||||
|
||||
@click.command(name="snap")
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
@DB_OPTION
|
||||
def snap(ctx, cli_obj, db):
|
||||
"""Create snapshot of Photos database to use with diff command
|
||||
|
||||
Snapshots only the database files, not the entire library. If OSXPHOTOS_SNAPSHOT
|
||||
environment variable is defined, will use that as snapshot directory, otherwise
|
||||
uses '/private/tmp/osxphotos_snapshots'
|
||||
|
||||
Works only on Photos library versions since Catalina (10.15) or newer.
|
||||
"""
|
||||
|
||||
db = get_photos_db(db, cli_obj.db)
|
||||
db_path = pathlib.Path(db)
|
||||
if db_path.is_file():
|
||||
# assume it's the sqlite file
|
||||
db_path = db_path.parent.parent
|
||||
db_path = db_path / "database"
|
||||
|
||||
db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR)
|
||||
if not os.path.isdir(db_folder):
|
||||
click.echo(f"Creating snapshot folder: '{db_folder}'")
|
||||
os.mkdir(db_folder)
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
destination_path = pathlib.Path(db_folder) / timestamp
|
||||
|
||||
# get all the sqlite files including the write ahead log if any
|
||||
files = db_path.glob("*.sqlite*")
|
||||
os.makedirs(destination_path)
|
||||
fu = osxphotos.fileutil.FileUtil()
|
||||
count = 0
|
||||
for file in files:
|
||||
if file.is_file():
|
||||
fu.copy(file, destination_path)
|
||||
count += 1
|
||||
|
||||
print(f"Copied {count} files from {db_path} to {destination_path}")
|
||||
|
||||
|
||||
@click.command(name="diff")
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
@DB_OPTION
|
||||
@click.option(
|
||||
"--raw-output",
|
||||
"-r",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print raw output (don't use syntax highlighting).",
|
||||
)
|
||||
@click.option(
|
||||
"--style",
|
||||
"-s",
|
||||
metavar="STYLE",
|
||||
nargs=1,
|
||||
default="monokai",
|
||||
help="Specify style/theme for syntax highlighting. "
|
||||
"Theme may be any valid pygments style (https://pygments.org/styles/). "
|
||||
"Default is 'monokai'.",
|
||||
)
|
||||
@click.argument("db2", nargs=-1, type=click.Path(exists=True))
|
||||
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
|
||||
def diff(ctx, cli_obj, db, raw_output, style, db2, verbose):
|
||||
"""Compare two Photos databases and print out differences
|
||||
|
||||
To use the diff command, you'll need to install sqldiff via homebrew:
|
||||
|
||||
- Install homebrew (https://brew.sh/) if not already installed
|
||||
|
||||
- Install sqldiff: `brew install sqldiff`
|
||||
|
||||
When run with no arguments, compares the current Photos library to the
|
||||
most recent snapshot in the the OSXPHOTOS_SNAPSHOT directory.
|
||||
|
||||
If run with the --db option, compares the library specified by --db to the
|
||||
most recent snapshot in the the OSXPHOTOS_SNAPSHOT directory.
|
||||
|
||||
If run with just the DB2 argument, compares the current Photos library to
|
||||
the database specified by the DB2 argument.
|
||||
|
||||
If run with both the --db option and the DB2 argument, compares the
|
||||
library specified by --db to the database specified by DB2
|
||||
|
||||
See also `osxphotos snap`
|
||||
|
||||
If the OSXPHOTOS_SNAPSHOT environment variable is not set, will use
|
||||
'/private/tmp/osxphotos_snapshots'
|
||||
|
||||
Works only on Photos library versions since Catalina (10.15) or newer.
|
||||
"""
|
||||
|
||||
verbose_ = verbose_print(verbose, rich=True)
|
||||
|
||||
sqldiff = shutil.which("sqldiff")
|
||||
if not sqldiff:
|
||||
click.echo(
|
||||
"sqldiff not found; install via homebrew (https://brew.sh/): `brew install sqldiff`"
|
||||
)
|
||||
ctx.exit(2)
|
||||
verbose_(f"sqldiff found at '{sqldiff}'")
|
||||
|
||||
db = get_photos_db(db, cli_obj.db)
|
||||
db_path = pathlib.Path(db)
|
||||
if db_path.is_file():
|
||||
# assume it's the sqlite file
|
||||
db_path = db_path.parent.parent
|
||||
db_path = db_path / "database"
|
||||
db_1 = db_path / "photos.sqlite"
|
||||
|
||||
if db2:
|
||||
db_2 = pathlib.Path(db2[0])
|
||||
else:
|
||||
# get most recent snapshot
|
||||
db_folder = os.environ.get("OSXPHOTOS_SNAPSHOT", OSXPHOTOS_SNAPSHOT_DIR)
|
||||
verbose_(f"Using snapshot folder: '{db_folder}'")
|
||||
folders = sorted([f for f in pathlib.Path(db_folder).glob("*") if f.is_dir()])
|
||||
folder_2 = folders[-1]
|
||||
db_2 = folder_2 / "Photos.sqlite"
|
||||
|
||||
if not db_1.exists():
|
||||
print(f"database file {db_1} missing")
|
||||
if not db_2.exists():
|
||||
print(f"database file {db_2} missing")
|
||||
|
||||
verbose_(f"Comparing databases {db_1} and {db_2}")
|
||||
|
||||
diff_proc = subprocess.Popen([sqldiff, db_2, db_1], stdout=subprocess.PIPE)
|
||||
console = Console()
|
||||
for line in iter(diff_proc.stdout.readline, b""):
|
||||
line = line.decode("UTF-8").rstrip()
|
||||
if raw_output:
|
||||
print(line)
|
||||
else:
|
||||
syntax = Syntax(
|
||||
line, "sql", theme=style, line_numbers=False, code_width=1000
|
||||
)
|
||||
console.print(syntax)
|
||||
46
osxphotos/cli/tutorial.py
Normal file
46
osxphotos/cli/tutorial.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""tutorial command for osxphotos CLI"""
|
||||
|
||||
import io
|
||||
import pathlib
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from .help import strip_html_comments, strip_md_links
|
||||
|
||||
|
||||
@click.command(name="tutorial")
|
||||
@click.argument(
|
||||
"WIDTH",
|
||||
nargs=-1,
|
||||
type=click.INT,
|
||||
)
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
def tutorial(ctx, cli_obj, width):
|
||||
"""Display osxphotos tutorial."""
|
||||
width = width[0] if width else 100
|
||||
click.echo_via_pager(tutorial_help(width=width))
|
||||
|
||||
|
||||
def tutorial_help(width=78):
|
||||
"""Return formatted string for tutorial"""
|
||||
sio = io.StringIO()
|
||||
console = Console(file=sio, force_terminal=True, width=width)
|
||||
help_md = get_tutorial_text()
|
||||
help_md = strip_html_comments(help_md)
|
||||
help_md = strip_md_links(help_md)
|
||||
console.print(Markdown(help_md))
|
||||
help_str = sio.getvalue()
|
||||
sio.close()
|
||||
return help_str
|
||||
|
||||
|
||||
def get_tutorial_text():
|
||||
"""Load tutorial text from file"""
|
||||
# TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this
|
||||
help_file = pathlib.Path(__file__).parent / "../tutorial.md"
|
||||
with open(help_file, "r") as fd:
|
||||
md = fd.read()
|
||||
return md
|
||||
26
osxphotos/cli/uuid.py
Normal file
26
osxphotos/cli/uuid.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""uuid command for osxphotos CLI"""
|
||||
|
||||
import click
|
||||
import photoscript
|
||||
|
||||
|
||||
@click.command(name="uuid")
|
||||
@click.pass_obj
|
||||
@click.pass_context
|
||||
@click.option(
|
||||
"--filename",
|
||||
"-f",
|
||||
required=False,
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Include filename of selected photos in output",
|
||||
)
|
||||
def uuid(ctx, cli_obj, filename):
|
||||
"""Print out unique IDs (UUID) of photos selected in Photos
|
||||
|
||||
Prints outs UUIDs in form suitable for --uuid-from-file and --skip-uuid-from-file
|
||||
"""
|
||||
for photo in photoscript.PhotosLibrary().selection:
|
||||
if filename:
|
||||
print(f"# {photo.filename}")
|
||||
print(photo.uuid)
|
||||
@@ -1,9 +1,17 @@
|
||||
""" ConfigOptions class to load/save config settings for osxphotos CLI """
|
||||
import bitmath
|
||||
import toml
|
||||
|
||||
__all__ = [
|
||||
"ConfigOptionsException",
|
||||
"ConfigOptionsInvalidError",
|
||||
"ConfigOptionsLoadError",
|
||||
"ConfigOptions",
|
||||
]
|
||||
|
||||
|
||||
class ConfigOptionsException(Exception):
|
||||
""" Invalid combination of options. """
|
||||
"""Invalid combination of options."""
|
||||
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
@@ -19,10 +27,10 @@ class ConfigOptionsLoadError(ConfigOptionsException):
|
||||
|
||||
|
||||
class ConfigOptions:
|
||||
""" data class to store and load options for osxphotos commands """
|
||||
"""data class to store and load options for osxphotos commands"""
|
||||
|
||||
def __init__(self, name, attrs, ignore=None):
|
||||
""" init ConfigOptions class
|
||||
"""init ConfigOptions class
|
||||
|
||||
Args:
|
||||
name: name for these options, will be used for section heading in TOML file when saving/loading from file
|
||||
@@ -53,21 +61,21 @@ class ConfigOptions:
|
||||
raise KeyError(f"Missing argument: {attr}")
|
||||
|
||||
def validate(self, exclusive=None, inclusive=None, dependent=None, cli=False):
|
||||
""" validate combinations of otions
|
||||
|
||||
"""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;
|
||||
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"))...]
|
||||
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;
|
||||
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
|
||||
@@ -121,27 +129,21 @@ class ConfigOptions:
|
||||
return True
|
||||
|
||||
def write_to_file(self, filename):
|
||||
""" Write self to TOML file
|
||||
"""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)
|
||||
toml.dump(self._get_toml_dict(), fd)
|
||||
|
||||
def write_to_str(self) -> str:
|
||||
"""Write self to TOML str"""
|
||||
return toml.dumps(self._get_toml_dict())
|
||||
|
||||
def load_from_file(self, filename, override=False):
|
||||
""" Load options from a TOML file.
|
||||
"""Load options from a TOML file.
|
||||
|
||||
Args:
|
||||
filename: full path to TOML file
|
||||
@@ -171,3 +173,21 @@ class ConfigOptions:
|
||||
|
||||
def asdict(self):
|
||||
return {attr: getattr(self, attr) for attr in sorted(self._attrs.keys())}
|
||||
|
||||
def _get_toml_dict(self):
|
||||
"""Return dict for writing to TOML file"""
|
||||
data = {}
|
||||
for attr in sorted(self._attrs.keys()):
|
||||
val = getattr(self, attr)
|
||||
|
||||
if isinstance(val, bitmath.Bitmath):
|
||||
val = int(val.to_Byte())
|
||||
|
||||
if val in [False, ()]:
|
||||
val = None
|
||||
else:
|
||||
val = list(val) if type(val) == tuple else val
|
||||
|
||||
data[attr] = val
|
||||
|
||||
return {self._name: data}
|
||||
|
||||
46
osxphotos/crash_reporter.py
Normal file
46
osxphotos/crash_reporter.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Error logger/crash reporter decorator"""
|
||||
|
||||
import datetime
|
||||
import functools
|
||||
import platform
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from rich import print
|
||||
|
||||
|
||||
def crash_reporter(filename, message, title, postamble, *extra_args):
|
||||
"""Create a crash dump file on error named filename
|
||||
|
||||
On error, create a crash dump file named filename with exception and stack trace.
|
||||
message is printed to stderr
|
||||
title is printed at beginning of crash dump file
|
||||
postamble is printed to stderr after crash dump file is created
|
||||
If extra_args is not None, any additional arguments to the function will be printed to the file.
|
||||
"""
|
||||
|
||||
def decorated(func):
|
||||
@functools.wraps(func)
|
||||
def wrapped(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
print(message, file=sys.stderr)
|
||||
print(f"[red]{e}[/red]", file=sys.stderr)
|
||||
with open(filename, "w") as f:
|
||||
f.write(f"{title}\n")
|
||||
f.write(f"Created: {datetime.datetime.now()}\n")
|
||||
f.write(f"Python version: {sys.version}\n")
|
||||
f.write(f"Platform: {platform.platform()}\n")
|
||||
f.write(f"sys.argv: {sys.argv}\n")
|
||||
for arg in extra_args:
|
||||
f.write(f"{arg}\n")
|
||||
f.write(f"Error: {e}\n")
|
||||
traceback.print_exc(file=f)
|
||||
print(f"Crash log written to '{filename}'", file=sys.stderr)
|
||||
print(f"{postamble}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorated
|
||||
@@ -2,69 +2,71 @@
|
||||
|
||||
import datetime
|
||||
|
||||
__all__ = ["DateTimeFormatter"]
|
||||
|
||||
|
||||
class DateTimeFormatter:
|
||||
""" provides property access to formatted datetime.datetime strftime values """
|
||||
"""provides property access to formatted datetime.datetime strftime values"""
|
||||
|
||||
def __init__(self, dt: datetime.datetime):
|
||||
self.dt = dt
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
""" ISO date in form 2020-03-22 """
|
||||
"""ISO date in form 2020-03-22"""
|
||||
return self.dt.date().isoformat()
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" 4 digit year """
|
||||
"""4 digit year"""
|
||||
return f"{self.dt.year}"
|
||||
|
||||
@property
|
||||
def yy(self):
|
||||
""" 2 digit year """
|
||||
"""2 digit year"""
|
||||
return f"{self.dt.strftime('%y')}"
|
||||
|
||||
@property
|
||||
def mm(self):
|
||||
""" 2 digit month """
|
||||
"""2 digit month"""
|
||||
return f"{self.dt.strftime('%m')}"
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" Month as locale's full name """
|
||||
"""Month as locale's full name"""
|
||||
return f"{self.dt.strftime('%B')}"
|
||||
|
||||
@property
|
||||
def mon(self):
|
||||
""" Month as locale's abbreviated name """
|
||||
"""Month as locale's abbreviated name"""
|
||||
return f"{self.dt.strftime('%b')}"
|
||||
|
||||
@property
|
||||
def dd(self):
|
||||
""" 2-digit day of the month """
|
||||
"""2-digit day of the month"""
|
||||
return f"{self.dt.strftime('%d')}"
|
||||
|
||||
@property
|
||||
def dow(self):
|
||||
""" Day of week as locale's name """
|
||||
"""Day of week as locale's name"""
|
||||
return f"{self.dt.strftime('%A')}"
|
||||
|
||||
@property
|
||||
def doy(self):
|
||||
""" Julian day of year starting from 001 """
|
||||
"""Julian day of year starting from 001"""
|
||||
return f"{self.dt.strftime('%j')}"
|
||||
|
||||
@property
|
||||
def hour(self):
|
||||
""" 2-digit hour """
|
||||
"""2-digit hour"""
|
||||
return f"{self.dt.strftime('%H')}"
|
||||
|
||||
@property
|
||||
def min(self):
|
||||
""" 2-digit minute """
|
||||
"""2-digit minute"""
|
||||
return f"{self.dt.strftime('%M')}"
|
||||
|
||||
@property
|
||||
def sec(self):
|
||||
""" 2-digit second """
|
||||
"""2-digit second"""
|
||||
return f"{self.dt.strftime('%S')}"
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
|
||||
import datetime
|
||||
|
||||
__all__ = [
|
||||
"get_local_tz",
|
||||
"datetime_has_tz",
|
||||
"datetime_tz_to_utc",
|
||||
"datetime_remove_tz",
|
||||
"datetime_naive_to_utc",
|
||||
"datetime_naive_to_local",
|
||||
"datetime_utc_to_local",
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
Returns:
|
||||
local timezone for dt as datetime.timezone
|
||||
|
||||
@@ -22,14 +32,14 @@ def get_local_tz(dt):
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
Raises:
|
||||
TypeError if dt is not a datetime.datetime object
|
||||
"""
|
||||
@@ -41,15 +51,15 @@ def datetime_has_tz(dt):
|
||||
|
||||
|
||||
def datetime_tz_to_utc(dt):
|
||||
""" Convert datetime.datetime object with timezone to UTC timezone
|
||||
"""Convert datetime.datetime object with timezone to UTC timezone
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime object
|
||||
|
||||
Returns:
|
||||
datetime.datetime in UTC timezone
|
||||
|
||||
Raises:
|
||||
|
||||
Raises:
|
||||
TypeError if dt is not datetime.datetime object
|
||||
ValueError if dt does not have timeone information
|
||||
"""
|
||||
@@ -64,14 +74,14 @@ def datetime_tz_to_utc(dt):
|
||||
|
||||
|
||||
def datetime_remove_tz(dt):
|
||||
""" Remove timezone from a datetime.datetime object
|
||||
"""Remove timezone from a datetime.datetime object
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime object with tzinfo
|
||||
|
||||
|
||||
Returns:
|
||||
dt without any timezone info (naive datetime object)
|
||||
|
||||
dt without any timezone info (naive datetime object)
|
||||
|
||||
Raises:
|
||||
TypeError if dt is not a datetime.datetime object
|
||||
"""
|
||||
@@ -83,15 +93,15 @@ def datetime_remove_tz(dt):
|
||||
|
||||
|
||||
def datetime_naive_to_utc(dt):
|
||||
""" Convert naive (timezone unaware) datetime.datetime
|
||||
"""Convert naive (timezone unaware) datetime.datetime
|
||||
to aware timezone in UTC timezone
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime without 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
|
||||
@@ -111,15 +121,15 @@ def datetime_naive_to_utc(dt):
|
||||
|
||||
|
||||
def datetime_naive_to_local(dt):
|
||||
""" Convert naive (timezone unaware) datetime.datetime
|
||||
"""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
|
||||
@@ -139,7 +149,7 @@ def datetime_naive_to_local(dt):
|
||||
|
||||
|
||||
def datetime_utc_to_local(dt):
|
||||
""" Convert datetime.datetime object in UTC timezone to local timezone
|
||||
"""Convert datetime.datetime object in UTC timezone to local timezone
|
||||
|
||||
Args:
|
||||
dt: datetime.datetime object
|
||||
|
||||
30
osxphotos/exifinfo.py
Normal file
30
osxphotos/exifinfo.py
Normal file
@@ -0,0 +1,30 @@
|
||||
""" ExifInfo class to expose EXIF info from the library """
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = ["ExifInfo"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExifInfo:
|
||||
"""EXIF info associated with a photo from the Photos library"""
|
||||
|
||||
flash_fired: bool
|
||||
iso: int
|
||||
metering_mode: int
|
||||
sample_rate: int
|
||||
track_format: int
|
||||
white_balance: int
|
||||
aperture: float
|
||||
bit_rate: float
|
||||
duration: float
|
||||
exposure_bias: float
|
||||
focal_length: float
|
||||
fps: float
|
||||
latitude: float
|
||||
longitude: float
|
||||
shutter_speed: float
|
||||
camera_make: str
|
||||
camera_model: str
|
||||
codec: str
|
||||
lens_model: str
|
||||
@@ -6,22 +6,84 @@
|
||||
If these aren't important to you, I highly recommend you use Sven Marnach's excellent
|
||||
pyexiftool: https://github.com/smarnach/pyexiftool which provides more functionality """
|
||||
|
||||
import atexit
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from abc import ABC, abstractmethod
|
||||
from functools import lru_cache # pylint: disable=syntax-error
|
||||
|
||||
__all__ = [
|
||||
"escape_str",
|
||||
"exiftool_can_write",
|
||||
"ExifTool",
|
||||
"ExifToolCaching",
|
||||
"get_exiftool_path",
|
||||
"terminate_exiftool",
|
||||
"unescape_str",
|
||||
]
|
||||
|
||||
# exiftool -stay_open commands outputs this EOF marker after command is run
|
||||
EXIFTOOL_STAYOPEN_EOF = "{ready}"
|
||||
EXIFTOOL_STAYOPEN_EOF_LEN = len(EXIFTOOL_STAYOPEN_EOF)
|
||||
|
||||
# list of exiftool processes to cleanup when exiting or when terminate is called
|
||||
EXIFTOOL_PROCESSES = []
|
||||
|
||||
# exiftool supported file types, created by utils/exiftool_supported_types.py
|
||||
EXIFTOOL_FILETYPES_JSON = "exiftool_filetypes.json"
|
||||
with (pathlib.Path(__file__).parent / EXIFTOOL_FILETYPES_JSON).open("r") as f:
|
||||
EXIFTOOL_SUPPORTED_FILETYPES = json.load(f)
|
||||
|
||||
|
||||
def exiftool_can_write(suffix: str) -> bool:
|
||||
"""Return True if exiftool supports writing to a file with the given suffix, otherwise False"""
|
||||
if not suffix:
|
||||
return False
|
||||
suffix = suffix.lower()
|
||||
if suffix[0] == ".":
|
||||
suffix = suffix[1:]
|
||||
return (
|
||||
suffix in EXIFTOOL_SUPPORTED_FILETYPES
|
||||
and EXIFTOOL_SUPPORTED_FILETYPES[suffix]["write"]
|
||||
)
|
||||
|
||||
|
||||
def escape_str(s):
|
||||
"""escape string for use with exiftool -E"""
|
||||
if type(s) != str:
|
||||
return s
|
||||
s = html.escape(s)
|
||||
s = s.replace("\n", "
")
|
||||
s = s.replace("\t", "	")
|
||||
s = s.replace("\r", "
")
|
||||
return s
|
||||
|
||||
|
||||
def unescape_str(s):
|
||||
"""unescape an HTML string returned by exiftool -E"""
|
||||
if type(s) != str:
|
||||
return s
|
||||
# avoid " in values which result in json.loads() throwing an exception, #636
|
||||
s = s.replace(""", '\\"')
|
||||
return html.unescape(s)
|
||||
|
||||
|
||||
@atexit.register
|
||||
def terminate_exiftool():
|
||||
"""Terminate any running ExifTool subprocesses; call this to cleanup when done using ExifTool"""
|
||||
for proc in EXIFTOOL_PROCESSES:
|
||||
proc._stop_proc()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_exiftool_path():
|
||||
""" return path of exiftool, cache result """
|
||||
"""return path of exiftool, cache result"""
|
||||
exiftool_path = shutil.which("exiftool")
|
||||
if exiftool_path:
|
||||
return exiftool_path.rstrip()
|
||||
@@ -37,7 +99,7 @@ class _ExifToolProc:
|
||||
Creates a singleton object"""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
""" create new object or return instance of already created singleton """
|
||||
"""create new object or return instance of already created singleton"""
|
||||
if not hasattr(cls, "instance") or not cls.instance:
|
||||
cls.instance = super().__new__(cls)
|
||||
|
||||
@@ -45,7 +107,8 @@ class _ExifToolProc:
|
||||
|
||||
def __init__(self, exiftool=None):
|
||||
"""construct _ExifToolProc singleton object or return instance of already created object
|
||||
exiftool: optional path to exiftool binary (if not provided, will search path to find it)"""
|
||||
exiftool: optional path to exiftool binary (if not provided, will search path to find it)
|
||||
"""
|
||||
|
||||
if hasattr(self, "_process_running") and self._process_running:
|
||||
# already running
|
||||
@@ -55,37 +118,40 @@ class _ExifToolProc:
|
||||
f"ignoring exiftool={exiftool}"
|
||||
)
|
||||
return
|
||||
|
||||
self._process_running = False
|
||||
self._exiftool = exiftool or get_exiftool_path()
|
||||
self._start_proc()
|
||||
|
||||
@property
|
||||
def process(self):
|
||||
""" return the exiftool subprocess """
|
||||
"""return the exiftool subprocess"""
|
||||
if self._process_running:
|
||||
return self._process
|
||||
else:
|
||||
raise ValueError("exiftool process is not running")
|
||||
self._start_proc()
|
||||
return self._process
|
||||
|
||||
@property
|
||||
def pid(self):
|
||||
""" return process id (PID) of the exiftool process """
|
||||
"""return process id (PID) of the exiftool process"""
|
||||
return self._process.pid
|
||||
|
||||
@property
|
||||
def exiftool(self):
|
||||
""" return path to exiftool process """
|
||||
"""return path to exiftool process"""
|
||||
return self._exiftool
|
||||
|
||||
def _start_proc(self):
|
||||
""" start exiftool in batch mode """
|
||||
"""start exiftool in batch mode"""
|
||||
|
||||
if self._process_running:
|
||||
logging.warning("exiftool already running: {self._process}")
|
||||
return
|
||||
|
||||
# open exiftool process
|
||||
# make sure /usr/bin at start of path so exiftool can find xattr (see #636)
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f'/usr/bin/:{env["PATH"]}'
|
||||
self._process = subprocess.Popen(
|
||||
[
|
||||
self._exiftool,
|
||||
@@ -97,40 +163,42 @@ class _ExifToolProc:
|
||||
"-n", # no print conversion (e.g. print tag values in machine readable format)
|
||||
"-P", # Preserve file modification date/time
|
||||
"-G", # print group name for each tag
|
||||
"-E", # escape tag values for HTML (allows use of HTML 
 for newlines)
|
||||
],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
self._process_running = True
|
||||
|
||||
EXIFTOOL_PROCESSES.append(self)
|
||||
|
||||
def _stop_proc(self):
|
||||
""" stop the exiftool process if it's running, otherwise, do nothing """
|
||||
"""stop the exiftool process if it's running, otherwise, do nothing"""
|
||||
|
||||
if not self._process_running:
|
||||
return
|
||||
|
||||
self._process.stdin.write(b"-stay_open\n")
|
||||
self._process.stdin.write(b"False\n")
|
||||
self._process.stdin.flush()
|
||||
try:
|
||||
self._process.stdin.write(b"-stay_open\n")
|
||||
self._process.stdin.write(b"False\n")
|
||||
self._process.stdin.flush()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
logging.warning(
|
||||
f"exiftool pid {self._process.pid} did not exit, killing it"
|
||||
)
|
||||
self._process.kill()
|
||||
self._process.communicate()
|
||||
|
||||
del self._process
|
||||
self._process_running = False
|
||||
|
||||
def __del__(self):
|
||||
self._stop_proc()
|
||||
|
||||
|
||||
class ExifTool:
|
||||
""" Basic exiftool interface for reading and writing EXIF tags """
|
||||
"""Basic exiftool interface for reading and writing EXIF tags"""
|
||||
|
||||
def __init__(self, filepath, exiftool=None, overwrite=True, flags=None):
|
||||
"""Create ExifTool object
|
||||
@@ -153,9 +221,12 @@ class ExifTool:
|
||||
# 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()
|
||||
|
||||
@property
|
||||
def _process(self):
|
||||
return self._exiftoolproc.process
|
||||
|
||||
def setvalue(self, tag, value):
|
||||
"""Set tag to value(s); if value is None, will delete tag
|
||||
|
||||
@@ -173,6 +244,7 @@ class ExifTool:
|
||||
|
||||
if value is None:
|
||||
value = ""
|
||||
value = escape_str(value)
|
||||
command = [f"-{tag}={value}"]
|
||||
if self.overwrite and not self._context_mgr:
|
||||
command.append("-overwrite_original")
|
||||
@@ -185,7 +257,7 @@ class ExifTool:
|
||||
return True
|
||||
else:
|
||||
_, _, error = self.run_commands(*command)
|
||||
return error is None
|
||||
return error == ""
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
"""Add one or more value(s) to tag
|
||||
@@ -217,6 +289,7 @@ class ExifTool:
|
||||
for value in values:
|
||||
if value is None:
|
||||
raise ValueError("Can't add None value to tag")
|
||||
value = escape_str(value)
|
||||
command.append(f"-{tag}+={value}")
|
||||
|
||||
if self.overwrite and not self._context_mgr:
|
||||
@@ -227,7 +300,7 @@ class ExifTool:
|
||||
return True
|
||||
else:
|
||||
_, _, error = self.run_commands(*command)
|
||||
return error is None
|
||||
return error == ""
|
||||
|
||||
def run_commands(self, *commands, no_file=False):
|
||||
"""Run commands in the exiftool process and return result.
|
||||
@@ -295,37 +368,40 @@ class ExifTool:
|
||||
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):
|
||||
""" return process id (PID) of the exiftool process """
|
||||
"""return process id (PID) of the exiftool process"""
|
||||
return self._process.pid
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
""" returns exiftool version """
|
||||
"""returns exiftool version"""
|
||||
ver, _, _ = self.run_commands("-ver", no_file=True)
|
||||
return ver.decode("utf-8")
|
||||
|
||||
def asdict(self, tag_groups=True):
|
||||
def asdict(self, tag_groups=True, normalized=False):
|
||||
"""return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
|
||||
Args:
|
||||
tag_groups: if True (default), dict keys have tag groups, e.g. "IPTC:Keywords"; if False, drops groups from keys, e.g. "Keywords"
|
||||
normalized: if True, dict keys are all normalized to lower case (default is False)
|
||||
"""
|
||||
json_str, _, _ = self.run_commands("-json")
|
||||
if not json_str:
|
||||
return dict()
|
||||
json_str = unescape_str(json_str.decode("utf-8"))
|
||||
|
||||
try:
|
||||
exifdict = json.loads(json_str)
|
||||
except Exception as e:
|
||||
# will fail with some commands, e.g --ext AVI which produces
|
||||
# 'No file with specified extension' instead of json
|
||||
logging.warning(f"error loading json returned by exiftool: {e} {json_str}")
|
||||
return dict()
|
||||
|
||||
exifdict = exifdict[0]
|
||||
if not tag_groups:
|
||||
# strip tag groups
|
||||
@@ -334,15 +410,20 @@ class ExifTool:
|
||||
k = re.sub(r".*:", "", k)
|
||||
exif_new[k] = v
|
||||
exifdict = exif_new
|
||||
|
||||
if normalized:
|
||||
exifdict = {k.lower(): v for (k, v) in exifdict.items()}
|
||||
|
||||
return exifdict
|
||||
|
||||
def json(self):
|
||||
""" returns JSON string containing all EXIF tags and values from exiftool """
|
||||
"""returns JSON string containing all EXIF tags and values from exiftool"""
|
||||
json, _, _ = self.run_commands("-json")
|
||||
json = unescape_str(json.decode("utf-8"))
|
||||
return json
|
||||
|
||||
def _read_exif(self):
|
||||
""" read exif data from file """
|
||||
"""read exif data from file"""
|
||||
data = self.asdict()
|
||||
self.data = {k: v for k, v in data.items()}
|
||||
|
||||
@@ -360,3 +441,73 @@ class ExifTool:
|
||||
elif self._commands:
|
||||
# run_commands sets self.warning and self.error as needed
|
||||
self.run_commands(*self._commands)
|
||||
|
||||
|
||||
class ExifToolCaching(ExifTool):
|
||||
"""Basic exiftool interface for reading and writing EXIF tags, with caching.
|
||||
Use this only when you know the file's EXIF data will not be changed by any external process.
|
||||
|
||||
Creates a singleton cached ExifTool instance"""
|
||||
|
||||
_singletons = {}
|
||||
|
||||
def __new__(cls, filepath, exiftool=None):
|
||||
"""create new object or return instance of already created singleton"""
|
||||
if filepath not in cls._singletons:
|
||||
cls._singletons[filepath] = _ExifToolCaching(filepath, exiftool=exiftool)
|
||||
return cls._singletons[filepath]
|
||||
|
||||
|
||||
class _ExifToolCaching(ExifTool):
|
||||
def __init__(self, filepath, exiftool=None):
|
||||
"""Create read-only ExifTool object that caches values
|
||||
|
||||
Args:
|
||||
file: path to image file
|
||||
exiftool: path to exiftool, if not specified will look in path
|
||||
|
||||
Returns:
|
||||
ExifTool instance
|
||||
"""
|
||||
self._json_cache = None
|
||||
self._asdict_cache = {}
|
||||
super().__init__(filepath, exiftool=exiftool, overwrite=False, flags=None)
|
||||
|
||||
def run_commands(self, *commands, no_file=False):
|
||||
if commands[0] not in ["-json", "-ver"]:
|
||||
raise NotImplementedError(f"{self.__class__} is read-only")
|
||||
return super().run_commands(*commands, no_file=no_file)
|
||||
|
||||
def setvalue(self, tag, value):
|
||||
raise NotImplementedError(f"{self.__class__} is read-only")
|
||||
|
||||
def addvalues(self, tag, *values):
|
||||
raise NotImplementedError(f"{self.__class__} is read-only")
|
||||
|
||||
def json(self):
|
||||
if not self._json_cache:
|
||||
self._json_cache = super().json()
|
||||
return self._json_cache
|
||||
|
||||
def asdict(self, tag_groups=True, normalized=False):
|
||||
"""return dictionary of all EXIF tags and values from exiftool
|
||||
returns empty dict if no tags
|
||||
|
||||
Args:
|
||||
tag_groups: if True (default), dict keys have tag groups, e.g. "IPTC:Keywords"; if False, drops groups from keys, e.g. "Keywords"
|
||||
normalized: if True, dict keys are all normalized to lower case (default is False)
|
||||
"""
|
||||
try:
|
||||
return self._asdict_cache[tag_groups][normalized]
|
||||
except KeyError:
|
||||
if tag_groups not in self._asdict_cache:
|
||||
self._asdict_cache[tag_groups] = {}
|
||||
self._asdict_cache[tag_groups][normalized] = super().asdict(
|
||||
tag_groups=tag_groups, normalized=normalized
|
||||
)
|
||||
return self._asdict_cache[tag_groups][normalized]
|
||||
|
||||
def flush_cache(self):
|
||||
"""Clear cached data so that calls to json or asdict return fresh data"""
|
||||
self._json_cache = None
|
||||
self._asdict_cache = {}
|
||||
|
||||
4976
osxphotos/exiftool_filetypes.json
Normal file
4976
osxphotos/exiftool_filetypes.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
255
osxphotos/export_db_utils.py
Normal file
255
osxphotos/export_db_utils.py
Normal file
@@ -0,0 +1,255 @@
|
||||
""" Utility functions for working with export_db """
|
||||
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
from typing import Callable, Optional, Tuple, Union
|
||||
|
||||
import toml
|
||||
from rich import print
|
||||
|
||||
from ._constants import OSXPHOTOS_EXPORT_DB
|
||||
from ._version import __version__
|
||||
from .utils import noop
|
||||
from .export_db import OSXPHOTOS_EXPORTDB_VERSION, ExportDB
|
||||
from .fileutil import FileUtil
|
||||
from .photosdb import PhotosDB
|
||||
|
||||
__all__ = [
|
||||
"export_db_check_signatures",
|
||||
"export_db_get_last_run",
|
||||
"export_db_get_version",
|
||||
"export_db_save_config_to_file",
|
||||
"export_db_touch_files",
|
||||
"export_db_update_signatures",
|
||||
"export_db_vacuum",
|
||||
]
|
||||
|
||||
|
||||
def isotime_from_ts(ts: int) -> str:
|
||||
"""Convert timestamp to ISO 8601 time string"""
|
||||
return datetime.datetime.fromtimestamp(ts).isoformat()
|
||||
|
||||
|
||||
def export_db_get_version(
|
||||
dbfile: Union[str, pathlib.Path]
|
||||
) -> Tuple[Optional[int], Optional[int]]:
|
||||
"""returns version from export database as tuple of (osxphotos version, export_db version)"""
|
||||
conn = sqlite3.connect(str(dbfile))
|
||||
c = conn.cursor()
|
||||
row = c.execute(
|
||||
"SELECT osxphotos, exportdb FROM version ORDER BY id DESC LIMIT 1;"
|
||||
).fetchone()
|
||||
if row:
|
||||
return (row[0], row[1])
|
||||
return (None, None)
|
||||
|
||||
|
||||
def export_db_vacuum(dbfile: Union[str, pathlib.Path]) -> None:
|
||||
"""Vacuum export database"""
|
||||
conn = sqlite3.connect(str(dbfile))
|
||||
c = conn.cursor()
|
||||
c.execute("VACUUM;")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def export_db_update_signatures(
|
||||
dbfile: Union[str, pathlib.Path],
|
||||
export_dir: Union[str, pathlib.Path],
|
||||
verbose_: Callable = noop,
|
||||
dry_run: bool = False,
|
||||
) -> Tuple[int, int]:
|
||||
"""Update signatures for all files found in the export database to match what's on disk
|
||||
|
||||
Returns: tuple of (updated, skipped)
|
||||
"""
|
||||
export_dir = pathlib.Path(export_dir)
|
||||
fileutil = FileUtil
|
||||
conn = sqlite3.connect(str(dbfile))
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT filepath_normalized, filepath FROM export_data;")
|
||||
rows = c.fetchall()
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
filepath_normalized = row[0]
|
||||
filepath = row[1]
|
||||
filepath = export_dir / filepath
|
||||
if not os.path.exists(filepath):
|
||||
skipped += 1
|
||||
verbose_(f"[dark_orange]Skipping missing file[/dark_orange]: '{filepath}'")
|
||||
continue
|
||||
updated += 1
|
||||
file_sig = fileutil.file_sig(filepath)
|
||||
verbose_(f"[green]Updating signature for[/green]: '{filepath}'")
|
||||
if not dry_run:
|
||||
c.execute(
|
||||
"UPDATE export_data SET dest_mode = ?, dest_size = ?, dest_mtime = ? WHERE filepath_normalized = ?;",
|
||||
(file_sig[0], file_sig[1], file_sig[2], filepath_normalized),
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
conn.commit()
|
||||
|
||||
return (updated, skipped)
|
||||
|
||||
|
||||
def export_db_get_last_run(
|
||||
export_db: Union[str, pathlib.Path]
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Get last run from export database"""
|
||||
conn = sqlite3.connect(str(export_db))
|
||||
c = conn.cursor()
|
||||
row = c.execute(
|
||||
"SELECT datetime, args FROM runs ORDER BY id DESC LIMIT 1;"
|
||||
).fetchone()
|
||||
if row:
|
||||
return row[0], row[1]
|
||||
return None, None
|
||||
|
||||
|
||||
def export_db_save_config_to_file(
|
||||
export_db: Union[str, pathlib.Path], config_file: Union[str, pathlib.Path]
|
||||
) -> None:
|
||||
"""Save export_db last run config to file"""
|
||||
export_db = pathlib.Path(export_db)
|
||||
config_file = pathlib.Path(config_file)
|
||||
conn = sqlite3.connect(str(export_db))
|
||||
c = conn.cursor()
|
||||
row = c.execute("SELECT config FROM config ORDER BY id DESC LIMIT 1;").fetchone()
|
||||
if not row:
|
||||
return ValueError("No config found in export_db")
|
||||
with config_file.open("w") as f:
|
||||
f.write(row[0])
|
||||
|
||||
|
||||
def export_db_check_signatures(
|
||||
dbfile: Union[str, pathlib.Path],
|
||||
export_dir: Union[str, pathlib.Path],
|
||||
verbose_: Callable = noop,
|
||||
) -> Tuple[int, int, int]:
|
||||
"""Check signatures for all files found in the export database to verify what matches the on disk files
|
||||
|
||||
Returns: tuple of (updated, skipped)
|
||||
"""
|
||||
export_dir = pathlib.Path(export_dir)
|
||||
fileutil = FileUtil
|
||||
conn = sqlite3.connect(str(dbfile))
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT filepath_normalized, filepath FROM export_data;")
|
||||
rows = c.fetchall()
|
||||
exportdb = ExportDB(dbfile, export_dir)
|
||||
matched = 0
|
||||
notmatched = 0
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
filepath_normalized = row[0]
|
||||
filepath = row[1]
|
||||
filepath = export_dir / filepath
|
||||
if not filepath.exists():
|
||||
skipped += 1
|
||||
verbose_(f"[dark_orange]Skipping missing file[/dark_orange]: '{filepath}'")
|
||||
continue
|
||||
file_sig = fileutil.file_sig(filepath)
|
||||
file_rec = exportdb.get_file_record(filepath)
|
||||
if file_rec.dest_sig == file_sig:
|
||||
matched += 1
|
||||
verbose_(f"[green]Signatures matched[/green]: '{filepath}'")
|
||||
else:
|
||||
notmatched += 1
|
||||
verbose_(f"[deep_pink3]Signatures do not match[/deep_pink3]: '{filepath}'")
|
||||
|
||||
return (matched, notmatched, skipped)
|
||||
|
||||
|
||||
def export_db_touch_files(
|
||||
dbfile: Union[str, pathlib.Path],
|
||||
export_dir: Union[str, pathlib.Path],
|
||||
verbose_: Callable = noop,
|
||||
dry_run: bool = False,
|
||||
) -> Tuple[int, int, int]:
|
||||
"""Touch files on disk to match the Photos library created date
|
||||
|
||||
Returns: tuple of (touched, not_touched, skipped)
|
||||
"""
|
||||
export_dir = pathlib.Path(export_dir)
|
||||
|
||||
# open and close exportdb to ensure it gets migrated
|
||||
exportdb = ExportDB(dbfile, export_dir)
|
||||
upgraded = exportdb.was_upgraded
|
||||
if upgraded:
|
||||
verbose_(
|
||||
f"Upgraded export database {dbfile} from version {upgraded[0]} to {upgraded[1]}"
|
||||
)
|
||||
exportdb.close()
|
||||
|
||||
conn = sqlite3.connect(str(dbfile))
|
||||
c = conn.cursor()
|
||||
# get most recent config
|
||||
row = c.execute("SELECT config FROM config ORDER BY id DESC LIMIT 1;").fetchone()
|
||||
if row:
|
||||
config = toml.loads(row[0])
|
||||
try:
|
||||
photos_db_path = config["export"].get("db", None)
|
||||
except KeyError:
|
||||
photos_db_path = None
|
||||
else:
|
||||
# TODO: parse the runs table to get the last --db
|
||||
# in the mean time, photos_db_path = None will use the default library
|
||||
photos_db_path = None
|
||||
|
||||
photosdb = PhotosDB(dbfile=photos_db_path, verbose=verbose_)
|
||||
exportdb = ExportDB(dbfile, export_dir)
|
||||
c.execute(
|
||||
"SELECT filepath_normalized, filepath, uuid, dest_mode, dest_size FROM export_data;"
|
||||
)
|
||||
rows = c.fetchall()
|
||||
touched = 0
|
||||
not_touched = 0
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
filepath_normalized = row[0]
|
||||
filepath = row[1]
|
||||
filepath = export_dir / filepath
|
||||
uuid = row[2]
|
||||
dest_mode = row[3]
|
||||
dest_size = row[4]
|
||||
if not filepath.exists():
|
||||
skipped += 1
|
||||
verbose_(
|
||||
f"[dark_orange]Skipping missing file (not in export directory)[/dark_orange]: '{filepath}'"
|
||||
)
|
||||
continue
|
||||
|
||||
photo = photosdb.get_photo(uuid)
|
||||
if not photo:
|
||||
skipped += 1
|
||||
verbose_(
|
||||
f"[dark_orange]Skipping missing photo (did not find in Photos Library)[/dark_orange]: '{filepath}' ({uuid})"
|
||||
)
|
||||
continue
|
||||
|
||||
ts = int(photo.date.timestamp())
|
||||
stat = os.stat(str(filepath))
|
||||
mtime = stat.st_mtime
|
||||
if mtime == ts:
|
||||
not_touched += 1
|
||||
verbose_(
|
||||
f"[green]Skipping file (timestamp matches)[/green]: '{filepath}' [dodger_blue1]{isotime_from_ts(ts)} ({ts})[/dodger_blue1]"
|
||||
)
|
||||
continue
|
||||
|
||||
touched += 1
|
||||
verbose_(
|
||||
f"[deep_pink3]Touching file[/deep_pink3]: '{filepath}' "
|
||||
f"[dodger_blue1]{isotime_from_ts(mtime)} ({mtime}) -> {isotime_from_ts(ts)} ({ts})[/dodger_blue1]"
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
os.utime(str(filepath), (ts, ts))
|
||||
rec = exportdb.get_file_record(filepath)
|
||||
rec.dest_sig = (dest_mode, dest_size, ts)
|
||||
|
||||
return (touched, not_touched, skipped)
|
||||
@@ -7,13 +7,15 @@ import subprocess
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
|
||||
from .imageconverter import ImageConverter
|
||||
|
||||
__all__ = ["FileUtilABC", "FileUtilMacOS", "FileUtil", "FileUtilNoOp"]
|
||||
|
||||
|
||||
class FileUtilABC(ABC):
|
||||
""" Abstract base class for FileUtil """
|
||||
"""Abstract base class for FileUtil"""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
@@ -67,14 +69,14 @@ class FileUtilABC(ABC):
|
||||
|
||||
|
||||
class FileUtilMacOS(FileUtilABC):
|
||||
""" Various file utilities """
|
||||
"""Various file utilities"""
|
||||
|
||||
@classmethod
|
||||
def hardlink(cls, src, dest):
|
||||
""" Hardlinks a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
Raises exception if linking fails or either path is None """
|
||||
"""Hardlinks a file from src path to dest path
|
||||
src: source path as string
|
||||
dest: destination path as string
|
||||
Raises exception if linking fails or either path is None"""
|
||||
|
||||
if src is None or dest is None:
|
||||
raise ValueError("src and dest must not be None", src, dest)
|
||||
@@ -90,17 +92,17 @@ class FileUtilMacOS(FileUtilABC):
|
||||
|
||||
@classmethod
|
||||
def copy(cls, src, dest):
|
||||
""" Copies a file from src path to dest path
|
||||
|
||||
"""Copies a file from src path to dest path
|
||||
|
||||
Args:
|
||||
src: source path as string; must be a valid file path
|
||||
dest: destination path as string
|
||||
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
|
||||
@@ -114,7 +116,7 @@ class FileUtilMacOS(FileUtilABC):
|
||||
if dest.is_dir():
|
||||
dest /= src.name
|
||||
|
||||
filemgr = CoreFoundation.NSFileManager.defaultManager()
|
||||
filemgr = Foundation.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
|
||||
@@ -124,7 +126,7 @@ class FileUtilMacOS(FileUtilABC):
|
||||
|
||||
@classmethod
|
||||
def unlink(cls, filepath):
|
||||
""" unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink """
|
||||
"""unlink filepath; if it's pathlib.Path, use Path.unlink, otherwise use os.unlink"""
|
||||
if isinstance(filepath, pathlib.Path):
|
||||
filepath.unlink()
|
||||
else:
|
||||
@@ -132,7 +134,7 @@ class FileUtilMacOS(FileUtilABC):
|
||||
|
||||
@classmethod
|
||||
def rmdir(cls, dirpath):
|
||||
""" remove directory filepath; dirpath must be empty """
|
||||
"""remove directory filepath; dirpath must be empty"""
|
||||
if isinstance(dirpath, pathlib.Path):
|
||||
dirpath.rmdir()
|
||||
else:
|
||||
@@ -140,8 +142,8 @@ class FileUtilMacOS(FileUtilABC):
|
||||
|
||||
@classmethod
|
||||
def utime(cls, path, times):
|
||||
""" Set the access and modified time of path. """
|
||||
os.utime(path, times)
|
||||
"""Set the access and modified time of path."""
|
||||
os.utime(path, times=times)
|
||||
|
||||
@classmethod
|
||||
def cmp(cls, f1, f2, mtime1=None):
|
||||
@@ -152,7 +154,7 @@ class FileUtilMacOS(FileUtilABC):
|
||||
mtime1 -- optional, pass alternate file modification timestamp for f1; will be converted to int
|
||||
|
||||
Return value:
|
||||
True if the file signatures as returned by stat are the same, False otherwise.
|
||||
True if the file signatures as returned by stat are the same, False otherwise.
|
||||
Does not do a byte-by-byte comparison.
|
||||
"""
|
||||
|
||||
@@ -179,27 +181,26 @@ class FileUtilMacOS(FileUtilABC):
|
||||
return False
|
||||
|
||||
s1 = cls._sig(os.stat(f1))
|
||||
|
||||
if s1[0] != stat.S_IFREG or s2[0] != stat.S_IFREG:
|
||||
return False
|
||||
return s1 == s2
|
||||
|
||||
@classmethod
|
||||
def file_sig(cls, f1):
|
||||
""" return os.stat signature for file f1 """
|
||||
"""return os.stat signature for file f1 as tuple of (mode, size, mtime)"""
|
||||
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
|
||||
"""converts image file src_file to jpeg format as dest_file
|
||||
|
||||
Args:
|
||||
src_file: image file to convert
|
||||
dest_file: destination path to write converted file to
|
||||
compression quality: JPEG compression quality in range 0.0 <= compression_quality <= 1.0; default 1.0 (best quality)
|
||||
|
||||
Returns:
|
||||
True if success, otherwise False
|
||||
Args:
|
||||
src_file: image file to convert
|
||||
dest_file: destination path to write converted file to
|
||||
compression quality: JPEG compression quality in range 0.0 <= compression_quality <= 1.0; default 1.0 (best quality)
|
||||
|
||||
Returns:
|
||||
True if success, otherwise False
|
||||
"""
|
||||
converter = ImageConverter()
|
||||
return converter.write_jpeg(
|
||||
@@ -208,40 +209,40 @@ class FileUtilMacOS(FileUtilABC):
|
||||
|
||||
@classmethod
|
||||
def rename(cls, src, dest):
|
||||
""" Copy src to dest
|
||||
"""Copy src to dest
|
||||
|
||||
Args:
|
||||
src: path to source file
|
||||
dest: path to destination file
|
||||
|
||||
|
||||
Returns:
|
||||
Name of renamed file (dest)
|
||||
|
||||
|
||||
"""
|
||||
os.rename(str(src), str(dest))
|
||||
return dest
|
||||
|
||||
@staticmethod
|
||||
def _sig(st):
|
||||
""" return tuple of (mode, size, mtime) of file based on os.stat
|
||||
Args:
|
||||
st: os.stat signature
|
||||
"""return tuple of (mode, size, mtime) of file based on os.stat
|
||||
Args:
|
||||
st: os.stat signature
|
||||
"""
|
||||
# use int(st.st_mtime) because ditto does not copy fractional portion of mtime
|
||||
return (stat.S_IFMT(st.st_mode), st.st_size, int(st.st_mtime))
|
||||
|
||||
|
||||
class FileUtil(FileUtilMacOS):
|
||||
""" Various file utilities """
|
||||
"""Various file utilities"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FileUtilNoOp(FileUtil):
|
||||
""" No-Op implementation of FileUtil for testing / dry-run mode
|
||||
all methods with exception of cmp, cmp_file_sig and file_cmp are no-op
|
||||
cmp and cmp_file_sig functions as FileUtil methods do
|
||||
file_cmp returns mock data
|
||||
"""No-Op implementation of FileUtil for testing / dry-run mode
|
||||
all methods with exception of cmp, cmp_file_sig and file_cmp are no-op
|
||||
cmp and cmp_file_sig functions as FileUtil methods do
|
||||
file_cmp returns mock data
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -15,27 +15,29 @@ from Foundation import NSDictionary
|
||||
# needed to capture system-level stderr
|
||||
from wurlitzer import pipes
|
||||
|
||||
__all__ = ["ImageConversionError", "ImageConverter"]
|
||||
|
||||
|
||||
class ImageConversionError(Exception):
|
||||
"""Base class for exceptions in this module. """
|
||||
"""Base class for exceptions in this module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ImageConverter:
|
||||
""" Convert images to jpeg. This class is a singleton
|
||||
which will re-use the Core Image CIContext to avoid
|
||||
creating a new context for every conversion. """
|
||||
"""Convert images to jpeg. This class is a singleton
|
||||
which will re-use the Core Image CIContext to avoid
|
||||
creating a new context for every conversion."""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
""" create new object or return instance of already created singleton """
|
||||
"""create new object or return instance of already created singleton"""
|
||||
if not hasattr(cls, "instance") or not cls.instance:
|
||||
cls.instance = super().__new__(cls)
|
||||
|
||||
return cls.instance
|
||||
|
||||
def __init__(self):
|
||||
""" return existing singleton or create a new one """
|
||||
"""return existing singleton or create a new one"""
|
||||
|
||||
if hasattr(self, "context"):
|
||||
return
|
||||
@@ -47,13 +49,10 @@ class ImageConverter:
|
||||
"workingFormat": Quartz.kCIFormatRGBAh,
|
||||
}
|
||||
)
|
||||
mtldevice = Metal.MTLCreateSystemDefaultDevice()
|
||||
self.context = Quartz.CIContext.contextWithMTLDevice_options_(
|
||||
mtldevice, context_options
|
||||
)
|
||||
self.context = Quartz.CIContext.contextWithOptions_(context_options)
|
||||
|
||||
def write_jpeg(self, input_path, output_path, compression_quality=1.0):
|
||||
""" convert image to jpeg and write image to output_path
|
||||
"""convert image to jpeg and write image to output_path
|
||||
|
||||
Args:
|
||||
input_path: path to input image (e.g. '/path/to/import/file.CR2') as str or pathlib.Path
|
||||
@@ -104,8 +103,11 @@ class ImageConverter:
|
||||
if input_image is None:
|
||||
raise ImageConversionError(f"Could not create CIImage for {input_path}")
|
||||
|
||||
output_colorspace = input_image.colorSpace() or Quartz.CGColorSpaceCreateWithName(
|
||||
Quartz.CoreGraphics.kCGColorSpaceSRGB
|
||||
output_colorspace = (
|
||||
input_image.colorSpace()
|
||||
or Quartz.CGColorSpaceCreateWithName(
|
||||
Quartz.CoreGraphics.kCGColorSpaceSRGB
|
||||
)
|
||||
)
|
||||
|
||||
output_options = NSDictionary.dictionaryWithDictionary_(
|
||||
@@ -121,6 +123,5 @@ class ImageConverter:
|
||||
return True
|
||||
else:
|
||||
raise ImageConversionError(
|
||||
"Error converting file {input_path} to jpeg at {output_path}: {error}"
|
||||
f"Error converting file {input_path} to jpeg at {output_path}: {error}"
|
||||
)
|
||||
|
||||
|
||||
70
osxphotos/momentinfo.py
Normal file
70
osxphotos/momentinfo.py
Normal file
@@ -0,0 +1,70 @@
|
||||
__all__ = ["MomentInfo"]
|
||||
"""MomentInfo class with details about photo moments."""
|
||||
|
||||
|
||||
class MomentInfo:
|
||||
"""Info about a photo moment"""
|
||||
|
||||
def __init__(self, db, moment_pk):
|
||||
"""Initialize with a moment PK; returns None if PK not found."""
|
||||
self._db = db
|
||||
self._pk = moment_pk
|
||||
|
||||
self._moment = self._db._db_moment_pk.get(moment_pk)
|
||||
if not self._moment:
|
||||
raise ValueError(f"No moment with PK {moment_pk}")
|
||||
|
||||
@property
|
||||
def pk(self):
|
||||
"""Primary key of the moment."""
|
||||
return self._pk
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
"""Location of the moment."""
|
||||
return (self._moment.get("latitude"), self._moment.get("longitude"))
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
"""Title of the moment."""
|
||||
return self._moment.get("title")
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
"""Subtitle of the moment."""
|
||||
return self._moment.get("subtitle")
|
||||
|
||||
@property
|
||||
def start_date(self):
|
||||
"""Start date of the moment."""
|
||||
return self._moment.get("startDate")
|
||||
|
||||
@property
|
||||
def end_date(self):
|
||||
"""Stop date of the moment."""
|
||||
return self._moment.get("endDate")
|
||||
|
||||
@property
|
||||
def date(self):
|
||||
"""Date of the moment."""
|
||||
return self._moment.get("representativeDate")
|
||||
|
||||
@property
|
||||
def modification_date(self):
|
||||
"""Modification date of the moment."""
|
||||
return self._moment.get("modificationDate")
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
"""All photos in this moment"""
|
||||
try:
|
||||
return self._photos
|
||||
except AttributeError:
|
||||
photo_uuids = [
|
||||
uuid
|
||||
for uuid, photo in self._db._dbphotos.items()
|
||||
if photo["momentID"] == self._pk
|
||||
]
|
||||
|
||||
self._photos = self._db.photos_by_uuid(photo_uuids)
|
||||
return self._photos
|
||||
@@ -4,24 +4,32 @@ import pathvalidate
|
||||
|
||||
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
|
||||
|
||||
__all__ = [
|
||||
"sanitize_filepath",
|
||||
"is_valid_filepath",
|
||||
"sanitize_filename",
|
||||
"sanitize_dirname",
|
||||
"sanitize_pathpart",
|
||||
]
|
||||
|
||||
|
||||
def sanitize_filepath(filepath):
|
||||
""" sanitize a 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 """
|
||||
"""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
|
||||
"""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
|
||||
"""
|
||||
@@ -46,12 +54,12 @@ def sanitize_filename(filename, replacement=":"):
|
||||
|
||||
|
||||
def sanitize_dirname(dirname, replacement=":"):
|
||||
""" replace any illegal characters in a directory name and truncate directory name if needed
|
||||
"""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 = ":"
|
||||
|
||||
dirname: str, directory name to sanitize
|
||||
replacement: str, value to replace any illegal characters with; default = ":"; if None, no replacement occurs
|
||||
|
||||
Returns:
|
||||
dirname with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
@@ -61,19 +69,20 @@ def sanitize_dirname(dirname, replacement=":"):
|
||||
|
||||
|
||||
def sanitize_pathpart(pathpart, replacement=":"):
|
||||
""" replace any illegal characters in a path part (either directory or filename without extension) and truncate name if needed
|
||||
"""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 = ":"
|
||||
|
||||
pathpart: str, path part to sanitize
|
||||
replacement: str, value to replace any illegal characters with; default = ":"; if None, no replacement occurs
|
||||
|
||||
Returns:
|
||||
pathpart with any illegal characters replaced by replacement and truncated if necessary
|
||||
"""
|
||||
if pathpart:
|
||||
pathpart = pathpart.replace("/", replacement)
|
||||
pathpart = (
|
||||
pathpart.replace("/", replacement) if replacement is not None else pathpart
|
||||
)
|
||||
if len(pathpart) > MAX_DIRNAME_LEN:
|
||||
drop = len(pathpart) - MAX_DIRNAME_LEN
|
||||
pathpart = pathpart[:-drop]
|
||||
return pathpart
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import math
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
__all__ = ["PersonInfo", "FaceInfo", "rotate_image_point"]
|
||||
|
||||
MWG_RS_Area = namedtuple("MWG_RS_Area", ["x", "y", "h", "w"])
|
||||
MPRI_Reg_Rect = namedtuple("MPRI_Reg_Rect", ["x", "y", "h", "w"])
|
||||
|
||||
@@ -51,7 +53,7 @@ class PersonInfo:
|
||||
|
||||
@property
|
||||
def photos(self):
|
||||
""" Returns list of PhotoInfo objects associated with this person """
|
||||
"""Returns list of PhotoInfo objects associated with this person"""
|
||||
return self._db.photos_by_uuid(self._db._dbfaces_pk[self._pk])
|
||||
|
||||
@property
|
||||
@@ -71,7 +73,7 @@ class PersonInfo:
|
||||
return []
|
||||
|
||||
def asdict(self):
|
||||
""" Returns dictionary representation of class instance """
|
||||
"""Returns dictionary representation of class instance"""
|
||||
keyphoto = self.keyphoto.uuid if self.keyphoto is not None else None
|
||||
return {
|
||||
"uuid": self.uuid,
|
||||
@@ -83,7 +85,7 @@ class PersonInfo:
|
||||
}
|
||||
|
||||
def json(self):
|
||||
""" Returns JSON representation of class instance """
|
||||
"""Returns JSON representation of class instance"""
|
||||
return json.dumps(self.asdict())
|
||||
|
||||
def __str__(self):
|
||||
@@ -141,7 +143,7 @@ class FaceInfo:
|
||||
self.manual = face["manual"]
|
||||
self.face_type = face["facetype"]
|
||||
self.age_type = face["agetype"]
|
||||
self.bald_type = face["baldtype"]
|
||||
# self.bald_type = face["baldtype"]
|
||||
self.eye_makeup_type = face["eyemakeuptype"]
|
||||
self.eye_state = face["eyestate"]
|
||||
self.facial_hair_type = face["facialhairtype"]
|
||||
@@ -201,7 +203,7 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def person_info(self):
|
||||
""" PersonInfo instance for person associated with this face """
|
||||
"""PersonInfo instance for person associated with this face"""
|
||||
try:
|
||||
return self._person
|
||||
except AttributeError:
|
||||
@@ -210,7 +212,7 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def photo(self):
|
||||
""" PhotoInfo instance associated with this face """
|
||||
"""PhotoInfo instance associated with this face"""
|
||||
try:
|
||||
return self._photo
|
||||
except AttributeError:
|
||||
@@ -292,7 +294,7 @@ class FaceInfo:
|
||||
return [(x0, y0), (x1, y1)]
|
||||
|
||||
def roll_pitch_yaw(self):
|
||||
""" Roll, pitch, yaw of face in radians as tuple """
|
||||
"""Roll, pitch, yaw of face in radians as tuple"""
|
||||
info = self._info
|
||||
roll = 0 if info["roll"] is None else info["roll"]
|
||||
pitch = 0 if info["pitch"] is None else info["pitch"]
|
||||
@@ -302,19 +304,19 @@ class FaceInfo:
|
||||
|
||||
@property
|
||||
def roll(self):
|
||||
""" Return roll angle in radians of the face region """
|
||||
"""Return roll angle in radians of the face region"""
|
||||
roll, _, _ = self.roll_pitch_yaw()
|
||||
return roll
|
||||
|
||||
@property
|
||||
def pitch(self):
|
||||
""" Return pitch angle in radians of the face region """
|
||||
"""Return pitch angle in radians of the face region"""
|
||||
_, pitch, _ = self.roll_pitch_yaw()
|
||||
return pitch
|
||||
|
||||
@property
|
||||
def yaw(self):
|
||||
""" Return yaw angle in radians of the face region """
|
||||
"""Return yaw angle in radians of the face region"""
|
||||
_, _, yaw = self.roll_pitch_yaw()
|
||||
return yaw
|
||||
|
||||
@@ -402,7 +404,7 @@ class FaceInfo:
|
||||
return (int(xr), int(yr))
|
||||
|
||||
def asdict(self):
|
||||
""" Returns dict representation of class instance """
|
||||
"""Returns dict representation of class instance"""
|
||||
roll, pitch, yaw = self.roll_pitch_yaw()
|
||||
return {
|
||||
"_pk": self._pk,
|
||||
@@ -438,7 +440,7 @@ class FaceInfo:
|
||||
"manual": self.manual,
|
||||
"face_type": self.face_type,
|
||||
"age_type": self.age_type,
|
||||
"bald_type": self.bald_type,
|
||||
# "bald_type": self.bald_type,
|
||||
"eye_makeup_type": self.eye_makeup_type,
|
||||
"eye_state": self.eye_state,
|
||||
"facial_hair_type": self.facial_hair_type,
|
||||
@@ -451,7 +453,7 @@ class FaceInfo:
|
||||
}
|
||||
|
||||
def json(self):
|
||||
""" Return JSON representation of FaceInfo instance """
|
||||
"""Return JSON representation of FaceInfo instance"""
|
||||
return json.dumps(self.asdict())
|
||||
|
||||
def __str__(self):
|
||||
|
||||
2021
osxphotos/photoexporter.py
Normal file
2021
osxphotos/photoexporter.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user