Compare commits
393 Commits
v0.42.15
...
multiproce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bdd52df25 | ||
|
|
3cde0b79c9 | ||
|
|
e2bd262f75 | ||
|
|
db26532bab | ||
|
|
7a73b9168d | ||
|
|
a43bfc5a33 | ||
|
|
1d6bc4e09e | ||
|
|
3e14b718ef | ||
|
|
1ae6270561 | ||
|
|
55a601c07e | ||
|
|
7d67b81879 | ||
|
|
cd02144ac3 | ||
|
|
9b247acd1c | ||
|
|
942126ea3d | ||
|
|
2b9ea11701 | ||
|
|
b3d3e14ffe | ||
|
|
62ae5db9fd | ||
|
|
77a49a09a1 | ||
|
|
06c5bbfcfd | ||
|
|
f3063d35be | ||
|
|
e32090bf39 | ||
|
|
79dcfb38a8 | ||
|
|
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 |
@@ -213,6 +213,119 @@
|
||||
"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.7, 3.8, 3.9]
|
||||
|
||||
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/
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,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
|
||||
970
CHANGELOG.md
970
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
|
||||
12
README.rst
12
README.rst
@@ -16,8 +16,9 @@ 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.
|
||||
@@ -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
|
||||
---------------------------
|
||||
|
||||
|
||||
7
build.sh
7
build.sh
@@ -3,9 +3,10 @@
|
||||
# script to help build osxphotos release
|
||||
# this is unique to my own dev setup
|
||||
|
||||
activate osxphotos
|
||||
# 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
|
||||
./make_cli_exe.sh
|
||||
# python3 setup.py sdist bdist_wheel
|
||||
python3 -m build
|
||||
./make_cli_exe.sh
|
||||
|
||||
10
dev_requirements.txt
Normal file
10
dev_requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
build
|
||||
m2r2
|
||||
pyinstaller==4.4
|
||||
pytest-mock
|
||||
pytest==6.2.4
|
||||
sphinx_click
|
||||
sphinx_rtd_theme
|
||||
twine
|
||||
wheel
|
||||
Sphinx
|
||||
@@ -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: 03429d5418e3acb89bac82f09774e4ee
|
||||
config: bf43bf49b725c31ce72a8823e4f8012b
|
||||
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.42.15 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.43.9 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,7 +31,11 @@
|
||||
<div class="body" role="main">
|
||||
|
||||
<h1>All modules for which code is available</h1>
|
||||
<ul><li><a href="osxphotos/photoinfo/photoinfo.html">osxphotos.photoinfo.photoinfo</a></li>
|
||||
<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>
|
||||
<li><a href="osxphotos/photosdb/photosdb.html">osxphotos.photosdb.photosdb</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -67,7 +71,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>
|
||||
@@ -89,7 +93,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.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
File diff suppressed because it is too large
Load Diff
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.42.15',
|
||||
VERSION: '0.45.8',
|
||||
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
1474
docs/cli.html
1474
docs/cli.html
File diff suppressed because it is too large
Load Diff
2194
docs/genindex.html
2194
docs/genindex.html
File diff suppressed because it is too large
Load Diff
116
docs/index.html
116
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.42.15 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.45.8 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">
|
||||
</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.42.15 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.45.8 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.
1318
docs/reference.html
1318
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.42.15 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.45.8 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
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Export your photos — osxphotos 0.42.15 documentation</title>
|
||||
<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>
|
||||
|
||||
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
|
||||
@@ -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,11 +1,45 @@
|
||||
from ._constants import AlbumSortOrder
|
||||
from ._version import __version__
|
||||
from .exiftool import ExifTool
|
||||
from .export_db import ExportDB, ExportDBInMemory, ExportDBNoOp
|
||||
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",
|
||||
"ExportDBInMemory",
|
||||
"ExportDBNoOp",
|
||||
"ExportOptions",
|
||||
"ExportResults",
|
||||
"FileUtil",
|
||||
"FileUtilNoOp",
|
||||
"LikeInfo",
|
||||
"MomentInfo",
|
||||
"PersonInfo",
|
||||
"PhotoExporter",
|
||||
"PhotoInfo",
|
||||
"PhotosDB",
|
||||
"PhotoTemplate",
|
||||
"PlaceInfo",
|
||||
"QueryOptions",
|
||||
"ScoreInfo",
|
||||
"SearchInfo",
|
||||
]
|
||||
|
||||
@@ -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,12 @@ _TESTED_OS_VERSIONS = [
|
||||
("11", "0"),
|
||||
("11", "1"),
|
||||
("11", "2"),
|
||||
("11", "3"),
|
||||
("11", "4"),
|
||||
("11", "5"),
|
||||
("11", "6"),
|
||||
("12", "0"),
|
||||
("12", "1"),
|
||||
]
|
||||
|
||||
# Photos 5 has persons who are empty string if unidentified face
|
||||
@@ -95,12 +128,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
|
||||
@@ -173,7 +214,8 @@ SEARCH_CATEGORY_PHOTO_NAME = 2056
|
||||
|
||||
|
||||
# Max filename length on MacOS
|
||||
MAX_FILENAME_LEN = 255
|
||||
# subtract 6 chars for the lock file extension in form: ".filename.lock"
|
||||
MAX_FILENAME_LEN = 255 - 6
|
||||
|
||||
# Max directory name length on MacOS
|
||||
MAX_DIRNAME_LEN = 255
|
||||
@@ -187,6 +229,9 @@ DEFAULT_EDITED_SUFFIX = "_edited"
|
||||
# Default suffix to add to original images
|
||||
DEFAULT_ORIGINAL_SUFFIX = ""
|
||||
|
||||
# Default suffix to add to preview images
|
||||
DEFAULT_PREVIEW_SUFFIX = "_preview"
|
||||
|
||||
# Colors for print CLI messages
|
||||
CLI_COLOR_ERROR = "red"
|
||||
CLI_COLOR_WARNING = "yellow"
|
||||
@@ -201,10 +246,17 @@ 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]
|
||||
|
||||
@@ -212,8 +264,63 @@ EXTENDED_ATTRIBUTE_NAMES_QUOTED = [f"'{x}'" for x in EXTENDED_ATTRIBUTE_NAMES]
|
||||
OSXPHOTOS_EXPORT_DB = ".osxphotos_export.db"
|
||||
|
||||
# bit flags for burst images ("burstPickType")
|
||||
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
|
||||
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.42.15"
|
||||
__version__ = "0.45.8"
|
||||
|
||||
@@ -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)
|
||||
|
||||
3313
osxphotos/cli.py
3313
osxphotos/cli.py
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"""Help text helper class for osxphotos CLI """
|
||||
|
||||
import io
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import click
|
||||
@@ -12,16 +13,30 @@ from ._constants import (
|
||||
EXTENDED_ATTRIBUTE_NAMES,
|
||||
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
|
||||
OSXPHOTOS_EXPORT_DB,
|
||||
POST_COMMAND_CATEGORIES,
|
||||
)
|
||||
from .phototemplate import (
|
||||
TEMPLATE_SUBSTITUTIONS,
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
||||
TEMPLATE_SUBSTITUTIONS_PATHLIB,
|
||||
get_template_help,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ExportCommand",
|
||||
"template_help",
|
||||
"tutorial_help",
|
||||
"rich_text",
|
||||
"strip_md_header_and_links",
|
||||
"strip_md_links",
|
||||
"strip_html_comments",
|
||||
"get_tutorial_text",
|
||||
]
|
||||
|
||||
|
||||
# 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 +80,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 +116,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 +147,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,21 +174,127 @@ 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()
|
||||
return help_str
|
||||
|
||||
|
||||
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 rich_text(text, width=78):
|
||||
"""Return rich formatted text"""
|
||||
sio = io.StringIO()
|
||||
@@ -176,16 +305,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 +324,36 @@ 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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
""" ConfigOptions class to load/save config settings for osxphotos CLI """
|
||||
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 +26,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 +60,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,7 +128,7 @@ 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
|
||||
@@ -141,7 +148,7 @@ class ConfigOptions:
|
||||
toml.dump({self._name: data}, fd)
|
||||
|
||||
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
|
||||
|
||||
@@ -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,23 +6,82 @@
|
||||
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 functools import lru_cache # pylint: disable=syntax-error
|
||||
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
|
||||
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()
|
||||
@@ -38,7 +97,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)
|
||||
|
||||
@@ -63,24 +122,25 @@ class _ExifToolProc:
|
||||
|
||||
@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}")
|
||||
@@ -98,6 +158,7 @@ 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,
|
||||
@@ -105,33 +166,33 @@ class _ExifToolProc:
|
||||
)
|
||||
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
|
||||
@@ -154,9 +215,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
|
||||
|
||||
@@ -174,6 +238,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")
|
||||
@@ -186,7 +251,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
|
||||
@@ -218,6 +283,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:
|
||||
@@ -228,7 +294,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.
|
||||
@@ -300,12 +366,12 @@ class ExifTool:
|
||||
|
||||
@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")
|
||||
|
||||
@@ -320,6 +386,7 @@ class ExifTool:
|
||||
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)
|
||||
@@ -327,7 +394,6 @@ class ExifTool:
|
||||
# will fail with some commands, e.g --ext AVI which produces
|
||||
# 'No file with specified extension' instead of json
|
||||
return dict()
|
||||
|
||||
exifdict = exifdict[0]
|
||||
if not tag_groups:
|
||||
# strip tag groups
|
||||
@@ -343,12 +409,13 @@ class ExifTool:
|
||||
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()}
|
||||
|
||||
@@ -369,15 +436,15 @@ class ExifTool:
|
||||
|
||||
|
||||
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 """
|
||||
"""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 """
|
||||
"""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]
|
||||
@@ -433,7 +500,6 @@ class _ExifToolCaching(ExifTool):
|
||||
return self._asdict_cache[tag_groups][normalized]
|
||||
|
||||
def flush_cache(self):
|
||||
""" Clear cached data so that calls to json or asdict return fresh data """
|
||||
"""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
@@ -1,6 +1,5 @@
|
||||
""" Helper class for managing a database used by
|
||||
PhotoInfo.export for tracking state of exports and updates
|
||||
"""
|
||||
""" Helper class for managing a database used by PhotoInfo.export for tracking state of exports and updates """
|
||||
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
@@ -11,14 +10,22 @@ import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from io import StringIO
|
||||
from sqlite3 import Error
|
||||
from typing import Union
|
||||
|
||||
from ._constants import OSXPHOTOS_EXPORT_DB
|
||||
from ._version import __version__
|
||||
from .utils import normalize_fs_path
|
||||
|
||||
OSXPHOTOS_EXPORTDB_VERSION = "3.2"
|
||||
__all__ = ["ExportDB_ABC", "ExportDBNoOp", "ExportDB", "ExportDBInMemory"]
|
||||
|
||||
OSXPHOTOS_EXPORTDB_VERSION = "4.3"
|
||||
OSXPHOTOS_EXPORTDB_VERSION_MIGRATE_FILEPATH = "4.3"
|
||||
|
||||
OSXPHOTOS_ABOUT_STRING = f"Created by osxphotos version {__version__} (https://github.com/RhetTbull/osxphotos) on {datetime.datetime.now()}"
|
||||
|
||||
|
||||
class ExportDB_ABC(ABC):
|
||||
""" abstract base class for ExportDB """
|
||||
"""abstract base class for ExportDB"""
|
||||
|
||||
@abstractmethod
|
||||
def get_uuid_for_file(self, filename):
|
||||
@@ -88,23 +95,34 @@ class ExportDB_ABC(ABC):
|
||||
def get_previous_uuids(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_detected_text_for_uuid(self, uuid):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_detected_text_for_uuid(self, uuid, json_text):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_data(
|
||||
self,
|
||||
filename,
|
||||
uuid,
|
||||
orig_stat,
|
||||
exif_stat,
|
||||
converted_stat,
|
||||
edited_stat,
|
||||
info_json,
|
||||
exif_json,
|
||||
orig_stat=None,
|
||||
exif_stat=None,
|
||||
converted_stat=None,
|
||||
edited_stat=None,
|
||||
info_json=None,
|
||||
exif_json=None,
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_connection(self):
|
||||
pass
|
||||
|
||||
class ExportDBNoOp(ExportDB_ABC):
|
||||
""" An ExportDB with NoOp methods """
|
||||
"""An ExportDB with NoOp methods"""
|
||||
|
||||
def __init__(self):
|
||||
self.was_created = True
|
||||
@@ -162,78 +180,86 @@ class ExportDBNoOp(ExportDB_ABC):
|
||||
def get_previous_uuids(self):
|
||||
return []
|
||||
|
||||
def get_detected_text_for_uuid(self, uuid):
|
||||
return None
|
||||
|
||||
def set_detected_text_for_uuid(self, uuid, json_text):
|
||||
pass
|
||||
|
||||
def set_data(
|
||||
self,
|
||||
filename,
|
||||
uuid,
|
||||
orig_stat,
|
||||
exif_stat,
|
||||
converted_stat,
|
||||
edited_stat,
|
||||
info_json,
|
||||
exif_json,
|
||||
orig_stat=None,
|
||||
exif_stat=None,
|
||||
converted_stat=None,
|
||||
edited_stat=None,
|
||||
info_json=None,
|
||||
exif_json=None,
|
||||
):
|
||||
pass
|
||||
|
||||
def get_connection(self):
|
||||
pass
|
||||
|
||||
class ExportDB(ExportDB_ABC):
|
||||
""" Interface to sqlite3 database used to store state information for osxphotos export command """
|
||||
"""Interface to sqlite3 database used to store state information for osxphotos export command"""
|
||||
|
||||
def __init__(self, dbfile):
|
||||
""" dbfile: path to osxphotos export database file """
|
||||
def __init__(self, dbfile, export_dir):
|
||||
"""dbfile: path to osxphotos export database file"""
|
||||
self._dbfile = dbfile
|
||||
# _path is parent of the database
|
||||
# all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this parent _path
|
||||
# export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this path
|
||||
# this allows the entire export tree to be moved to a new disk/location
|
||||
# whilst preserving the UUID to filename mappping
|
||||
self._path = pathlib.Path(dbfile).parent
|
||||
# whilst preserving the UUID to filename mapping
|
||||
self._path = export_dir
|
||||
self._conn = self._open_export_db(dbfile)
|
||||
self._insert_run_info()
|
||||
|
||||
def get_uuid_for_file(self, filename):
|
||||
""" query database for filename and return UUID
|
||||
returns None if filename not found in database
|
||||
"""query database for filename and return UUID
|
||||
returns None if filename not found in database
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
filepath_normalized = self._normalize_filepath_relative(filename)
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"SELECT uuid FROM files WHERE filepath_normalized = ?", (filename,)
|
||||
"SELECT uuid FROM files WHERE filepath_normalized = ?",
|
||||
(filepath_normalized,),
|
||||
)
|
||||
results = c.fetchone()
|
||||
uuid = results[0] if results else None
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
uuid = None
|
||||
|
||||
return uuid
|
||||
|
||||
def set_uuid_for_file(self, filename, uuid):
|
||||
""" set UUID of filename to uuid in the database """
|
||||
"""set UUID of filename to uuid in the database"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path))
|
||||
filename_normalized = filename.lower()
|
||||
conn = self._conn
|
||||
filename_normalized = self._normalize_filepath(filename)
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
|
||||
"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
|
||||
(filename, filename_normalized, uuid),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def set_stat_orig_for_file(self, filename, stats):
|
||||
""" set stat info for filename
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
"""set stat info for filename
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime"""
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
if len(stats) != 3:
|
||||
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
|
||||
|
||||
conn = self._conn
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
@@ -247,11 +273,11 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
|
||||
def get_stat_orig_for_file(self, filename):
|
||||
""" get stat info for filename
|
||||
returns: tuple of (mode, size, mtime)
|
||||
"""get stat info for filename
|
||||
returns: tuple of (mode, size, mtime)
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
@@ -260,38 +286,37 @@ class ExportDB(ExportDB_ABC):
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
stats = results[:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
stats = (None, None, None)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
|
||||
stats = None, None, None
|
||||
return stats
|
||||
|
||||
def set_stat_edited_for_file(self, filename, stats):
|
||||
""" set stat info for edited version of image (in Photos' library)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
"""set stat info for edited version of image (in Photos' library)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime"""
|
||||
return self._set_stat_for_file("edited", filename, stats)
|
||||
|
||||
def get_stat_edited_for_file(self, filename):
|
||||
""" get stat info for edited version of image (in Photos' library)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
"""get stat info for edited version of image (in Photos' library)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime"""
|
||||
return self._get_stat_for_file("edited", filename)
|
||||
|
||||
def set_stat_exif_for_file(self, filename, stats):
|
||||
""" set stat info for filename (after exiftool has updated it)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
"""set stat info for filename (after exiftool has updated it)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime"""
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
if len(stats) != 3:
|
||||
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
|
||||
|
||||
conn = self._conn
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
@@ -305,11 +330,11 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
|
||||
def get_stat_exif_for_file(self, filename):
|
||||
""" get stat info for filename (after exiftool has updated it)
|
||||
returns: tuple of (mode, size, mtime)
|
||||
"""get stat info for filename (after exiftool has updated it)
|
||||
returns: tuple of (mode, size, mtime)
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
@@ -318,32 +343,31 @@ class ExportDB(ExportDB_ABC):
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
stats = results[:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
stats = (None, None, None)
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
stats = (None, None, None)
|
||||
|
||||
stats = None, None, None
|
||||
return stats
|
||||
|
||||
def set_stat_converted_for_file(self, filename, stats):
|
||||
""" set stat info for filename (after image converted to jpeg)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime """
|
||||
"""set stat info for filename (after image converted to jpeg)
|
||||
filename: filename to set the stat info for
|
||||
stat: a tuple of length 3: mode, size, mtime"""
|
||||
return self._set_stat_for_file("converted", filename, stats)
|
||||
|
||||
def get_stat_converted_for_file(self, filename):
|
||||
""" get stat info for filename (after jpeg conversion)
|
||||
returns: tuple of (mode, size, mtime)
|
||||
"""get stat info for filename (after jpeg conversion)
|
||||
returns: tuple of (mode, size, mtime)
|
||||
"""
|
||||
return self._get_stat_for_file("converted", filename)
|
||||
|
||||
def get_info_for_uuid(self, uuid):
|
||||
""" returns the info JSON struct for a UUID """
|
||||
conn = self._conn
|
||||
"""returns the info JSON struct for a UUID"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT json_info FROM info WHERE uuid = ?", (uuid,))
|
||||
@@ -356,8 +380,8 @@ class ExportDB(ExportDB_ABC):
|
||||
return info
|
||||
|
||||
def set_info_for_uuid(self, uuid, info):
|
||||
""" sets the info JSON struct for a UUID """
|
||||
conn = self._conn
|
||||
"""sets the info JSON struct for a UUID"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
@@ -369,9 +393,9 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
|
||||
def get_exifdata_for_file(self, filename):
|
||||
""" returns the exifdata JSON struct for a file """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
"""returns the exifdata JSON struct for a file"""
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
@@ -387,9 +411,9 @@ class ExportDB(ExportDB_ABC):
|
||||
return exifdata
|
||||
|
||||
def set_exifdata_for_file(self, filename, exifdata):
|
||||
""" sets the exifdata JSON struct for a file """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
"""sets the exifdata JSON struct for a file"""
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
@@ -401,9 +425,9 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
|
||||
def get_sidecar_for_file(self, filename):
|
||||
""" returns the sidecar data and signature for a file """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
"""returns the sidecar data and signature for a file"""
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
@@ -429,9 +453,9 @@ class ExportDB(ExportDB_ABC):
|
||||
return sidecar_data, sidecar_sig
|
||||
|
||||
def set_sidecar_for_file(self, filename, sidecar_data, sidecar_sig):
|
||||
""" sets the sidecar data and signature for a file """
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
"""sets the sidecar data and signature for a file"""
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
@@ -443,8 +467,8 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
|
||||
def get_previous_uuids(self):
|
||||
"""returns list of UUIDs of previously exported photos found in export database """
|
||||
conn = self._conn
|
||||
"""returns list of UUIDs of previously exported photos found in export database"""
|
||||
conn = self.get_connection()
|
||||
previous_uuids = []
|
||||
try:
|
||||
c = conn.cursor()
|
||||
@@ -455,73 +479,126 @@ class ExportDB(ExportDB_ABC):
|
||||
logging.warning(e)
|
||||
return previous_uuids
|
||||
|
||||
def set_data(
|
||||
self,
|
||||
filename,
|
||||
uuid,
|
||||
orig_stat,
|
||||
exif_stat,
|
||||
converted_stat,
|
||||
edited_stat,
|
||||
info_json,
|
||||
exif_json,
|
||||
):
|
||||
""" sets all the data for file and uuid at once
|
||||
"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path))
|
||||
filename_normalized = filename.lower()
|
||||
conn = self._conn
|
||||
def get_detected_text_for_uuid(self, uuid):
|
||||
"""Get the detected_text for a uuid"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT OR REPLACE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);",
|
||||
(filename, filename_normalized, uuid),
|
||||
"SELECT text_data FROM detected_text WHERE uuid = ?",
|
||||
(uuid,),
|
||||
)
|
||||
results = c.fetchone()
|
||||
detected_text = results[0] if results else None
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
detected_text = None
|
||||
|
||||
return detected_text
|
||||
|
||||
def set_detected_text_for_uuid(self, uuid, text_json):
|
||||
"""Set the detected text for uuid"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"UPDATE files "
|
||||
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
|
||||
+ "WHERE filepath_normalized = ?;",
|
||||
(*orig_stat, filename_normalized),
|
||||
)
|
||||
c.execute(
|
||||
"UPDATE files "
|
||||
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
|
||||
+ "WHERE filepath_normalized = ?;",
|
||||
(*exif_stat, filename_normalized),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO converted(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
|
||||
(filename_normalized, *converted_stat),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO edited(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
|
||||
(filename_normalized, *edited_stat),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
|
||||
(uuid, info_json),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
|
||||
(filename_normalized, exif_json),
|
||||
"INSERT OR REPLACE INTO detected_text(uuid, text_data) VALUES (?, ?);",
|
||||
(
|
||||
uuid,
|
||||
text_json,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def close(self):
|
||||
""" close the database connection """
|
||||
def set_data(
|
||||
self,
|
||||
filename,
|
||||
uuid,
|
||||
orig_stat=None,
|
||||
exif_stat=None,
|
||||
converted_stat=None,
|
||||
edited_stat=None,
|
||||
info_json=None,
|
||||
exif_json=None,
|
||||
):
|
||||
"""sets all the data for file and uuid at once; if any value is None, does not set it"""
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path))
|
||||
filename_normalized = self._normalize_filepath(filename)
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
self._conn.close()
|
||||
c = conn.cursor()
|
||||
# update files table (if needed);
|
||||
# this statement works around fact that there was no unique constraint on files.filepath_normalized
|
||||
c.execute(
|
||||
"""INSERT OR IGNORE INTO files(filepath, filepath_normalized, uuid) VALUES (?, ?, ?);""",
|
||||
(filename, filename_normalized, uuid),
|
||||
)
|
||||
|
||||
if orig_stat is not None:
|
||||
c.execute(
|
||||
"UPDATE files "
|
||||
+ "SET orig_mode = ?, orig_size = ?, orig_mtime = ? "
|
||||
+ "WHERE filepath_normalized = ?;",
|
||||
(*orig_stat, filename_normalized),
|
||||
)
|
||||
|
||||
if exif_stat is not None:
|
||||
c.execute(
|
||||
"UPDATE files "
|
||||
+ "SET exif_mode = ?, exif_size = ?, exif_mtime = ? "
|
||||
+ "WHERE filepath_normalized = ?;",
|
||||
(*exif_stat, filename_normalized),
|
||||
)
|
||||
|
||||
if converted_stat is not None:
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO converted(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
|
||||
(filename_normalized, *converted_stat),
|
||||
)
|
||||
|
||||
if edited_stat is not None:
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO edited(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
|
||||
(filename_normalized, *edited_stat),
|
||||
)
|
||||
|
||||
if info_json is not None:
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO info(uuid, json_info) VALUES (?, ?);",
|
||||
(uuid, info_json),
|
||||
)
|
||||
|
||||
if exif_json is not None:
|
||||
c.execute(
|
||||
"INSERT OR REPLACE INTO exifdata(filepath_normalized, json_exifdata) VALUES (?, ?);",
|
||||
(filename_normalized, exif_json),
|
||||
)
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def close(self):
|
||||
"""close the database connection"""
|
||||
try:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def get_connection(self):
|
||||
if self._conn is None:
|
||||
self._conn = self._open_export_db(self._dbfile)
|
||||
return self._conn
|
||||
|
||||
def _set_stat_for_file(self, table, filename, stats):
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
if len(stats) != 3:
|
||||
raise ValueError(f"expected 3 elements for stat, got {len(stats)}")
|
||||
|
||||
conn = self._conn
|
||||
conn = self.get_connection()
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT OR REPLACE INTO {table}(filepath_normalized, mode, size, mtime) VALUES (?, ?, ?, ?);",
|
||||
@@ -530,8 +607,8 @@ class ExportDB(ExportDB_ABC):
|
||||
conn.commit()
|
||||
|
||||
def _get_stat_for_file(self, table, filename):
|
||||
filename = str(pathlib.Path(filename).relative_to(self._path)).lower()
|
||||
conn = self._conn
|
||||
filename = self._normalize_filepath_relative(filename)
|
||||
conn = self.get_connection()
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"SELECT mode, size, mtime FROM {table} WHERE filepath_normalized = ?",
|
||||
@@ -539,7 +616,7 @@ class ExportDB(ExportDB_ABC):
|
||||
)
|
||||
results = c.fetchone()
|
||||
if results:
|
||||
stats = results[0:3]
|
||||
stats = results[:3]
|
||||
mtime = int(stats[2]) if stats[2] is not None else None
|
||||
stats = (stats[0], stats[1], mtime)
|
||||
else:
|
||||
@@ -548,9 +625,9 @@ class ExportDB(ExportDB_ABC):
|
||||
return stats
|
||||
|
||||
def _open_export_db(self, dbfile):
|
||||
""" open export database and return a db connection
|
||||
if dbfile does not exist, will create and initialize the database
|
||||
returns: connection to the database
|
||||
"""open export database and return a db connection
|
||||
if dbfile does not exist, will create and initialize the database
|
||||
returns: connection to the database
|
||||
"""
|
||||
|
||||
if not os.path.isfile(dbfile):
|
||||
@@ -566,14 +643,24 @@ class ExportDB(ExportDB_ABC):
|
||||
version_info = self._get_database_version(conn)
|
||||
if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION:
|
||||
self._create_db_tables(conn)
|
||||
if version_info[1] < OSXPHOTOS_EXPORTDB_VERSION_MIGRATE_FILEPATH:
|
||||
self._migrate_normalized_filepath(conn)
|
||||
self.was_upgraded = (version_info[1], OSXPHOTOS_EXPORTDB_VERSION)
|
||||
else:
|
||||
self.was_upgraded = ()
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
|
||||
# turn on performance optimizations
|
||||
c = conn.cursor()
|
||||
c.execute("PRAGMA journal_mode=WAL;")
|
||||
c.execute("PRAGMA synchronous=NORMAL;")
|
||||
c.execute("PRAGMA cache_size=-100000;")
|
||||
c.execute("PRAGMA temp_store=MEMORY;")
|
||||
|
||||
return conn
|
||||
|
||||
def _get_db_connection(self, dbfile):
|
||||
""" return db connection to dbname """
|
||||
"""return db connection to dbname"""
|
||||
try:
|
||||
conn = sqlite3.connect(dbfile)
|
||||
except Error as e:
|
||||
@@ -583,15 +670,15 @@ class ExportDB(ExportDB_ABC):
|
||||
return conn
|
||||
|
||||
def _get_database_version(self, conn):
|
||||
""" return tuple of (osxphotos, exportdb) versions for database connection conn """
|
||||
"""return tuple of (osxphotos, exportdb) versions for database connection conn"""
|
||||
version_info = conn.execute(
|
||||
"SELECT osxphotos, exportdb, max(id) FROM version"
|
||||
).fetchone()
|
||||
return (version_info[0], version_info[1])
|
||||
|
||||
def _create_db_tables(self, conn):
|
||||
""" create (if not already created) the necessary db tables for the export database
|
||||
conn: sqlite3 db connection
|
||||
"""create (if not already created) the necessary db tables for the export database
|
||||
conn: sqlite3 db connection
|
||||
"""
|
||||
sql_commands = {
|
||||
"sql_version_table": """ CREATE TABLE IF NOT EXISTS version (
|
||||
@@ -599,6 +686,10 @@ class ExportDB(ExportDB_ABC):
|
||||
osxphotos TEXT,
|
||||
exportdb TEXT
|
||||
); """,
|
||||
"sql_about_table": """ CREATE TABLE IF NOT EXISTS about (
|
||||
id INTEGER PRIMARY KEY,
|
||||
about TEXT
|
||||
);""",
|
||||
"sql_files_table": """ CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filepath TEXT NOT NULL,
|
||||
@@ -611,6 +702,22 @@ class ExportDB(ExportDB_ABC):
|
||||
exif_size INTEGER,
|
||||
exif_mtime REAL
|
||||
); """,
|
||||
"sql_files_table_migrate": """ CREATE TABLE IF NOT EXISTS files_migrate (
|
||||
id INTEGER PRIMARY KEY,
|
||||
filepath TEXT NOT NULL,
|
||||
filepath_normalized TEXT NOT NULL,
|
||||
uuid TEXT,
|
||||
orig_mode INTEGER,
|
||||
orig_size INTEGER,
|
||||
orig_mtime REAL,
|
||||
exif_mode INTEGER,
|
||||
exif_size INTEGER,
|
||||
exif_mtime REAL,
|
||||
UNIQUE(filepath_normalized)
|
||||
); """,
|
||||
"sql_files_migrate": """ INSERT INTO files_migrate SELECT * FROM files;""",
|
||||
"sql_files_drop_tables": """ DROP TABLE files;""",
|
||||
"sql_files_alter": """ ALTER TABLE files_migrate RENAME TO files;""",
|
||||
"sql_runs_table": """ CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
datetime TEXT,
|
||||
@@ -651,12 +758,18 @@ class ExportDB(ExportDB_ABC):
|
||||
size INTEGER,
|
||||
mtime REAL
|
||||
); """,
|
||||
"sql_detected_text_table": """ CREATE TABLE IF NOT EXISTS detected_text (
|
||||
id INTEGER PRIMARY KEY,
|
||||
uuid TEXT NOT NULL,
|
||||
text_data JSON
|
||||
); """,
|
||||
"sql_files_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_filepath_normalized on files (filepath_normalized); """,
|
||||
"sql_info_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_info_uuid on info (uuid); """,
|
||||
"sql_exifdata_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_exifdata_filename on exifdata (filepath_normalized); """,
|
||||
"sql_edited_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_edited_filename on edited (filepath_normalized);""",
|
||||
"sql_converted_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_converted_filename on converted (filepath_normalized);""",
|
||||
"sql_sidecar_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_sidecar_filename on sidecar (filepath_normalized);""",
|
||||
"sql_detected_text_idx": """ CREATE UNIQUE INDEX IF NOT EXISTS idx_detected_text on detected_text (uuid);""",
|
||||
}
|
||||
try:
|
||||
c = conn.cursor()
|
||||
@@ -666,12 +779,13 @@ class ExportDB(ExportDB_ABC):
|
||||
"INSERT INTO version(osxphotos, exportdb) VALUES (?, ?);",
|
||||
(__version__, OSXPHOTOS_EXPORTDB_VERSION),
|
||||
)
|
||||
c.execute("INSERT INTO about(about) VALUES (?);", (OSXPHOTOS_ABOUT_STRING,))
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def __del__(self):
|
||||
""" ensure the database connection is closed """
|
||||
"""ensure the database connection is closed"""
|
||||
try:
|
||||
self._conn.close()
|
||||
except:
|
||||
@@ -683,48 +797,72 @@ class ExportDB(ExportDB_ABC):
|
||||
cmd = sys.argv[0]
|
||||
args = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else ""
|
||||
cwd = os.getcwd()
|
||||
conn = self._conn
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
f"INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)",
|
||||
"INSERT INTO runs (datetime, python_path, script_name, args, cwd) VALUES (?, ?, ?, ?, ?)",
|
||||
(dt, python_path, cmd, args, cwd),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Error as e:
|
||||
logging.warning(e)
|
||||
|
||||
def _normalize_filepath(self, filepath: Union[str, pathlib.Path]) -> str:
|
||||
"""normalize filepath for unicode, lower case"""
|
||||
return normalize_fs_path(str(filepath)).lower()
|
||||
|
||||
def _normalize_filepath_relative(self, filepath: Union[str, pathlib.Path]) -> str:
|
||||
"""normalize filepath for unicode, relative path (to export dir), lower case"""
|
||||
filepath = str(pathlib.Path(filepath).relative_to(self._path))
|
||||
return normalize_fs_path(str(filepath)).lower()
|
||||
|
||||
def _migrate_normalized_filepath(self, conn):
|
||||
"""Fix all filepath_normalized columns for unicode normalization"""
|
||||
# Prior to database version 4.3, filepath_normalized was not normalized for unicode
|
||||
c = conn.cursor()
|
||||
for table in ["converted", "edited", "exifdata", "files", "sidecar"]:
|
||||
old_values = c.execute(
|
||||
f"SELECT filepath_normalized, id FROM {table}"
|
||||
).fetchall()
|
||||
new_values = [
|
||||
(self._normalize_filepath(filepath_normalized), id_)
|
||||
for filepath_normalized, id_ in old_values
|
||||
]
|
||||
c.executemany(
|
||||
f"UPDATE {table} SET filepath_normalized=? WHERE id=?", new_values
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
class ExportDBInMemory(ExportDB):
|
||||
""" In memory version of ExportDB
|
||||
Copies the on-disk database into memory so it may be operated on without
|
||||
modifying the on-disk verison
|
||||
"""In memory version of ExportDB
|
||||
Copies the on-disk database into memory so it may be operated on without
|
||||
modifying the on-disk version
|
||||
"""
|
||||
|
||||
def init(self, dbfile):
|
||||
self._dbfile = dbfile
|
||||
# _path is parent of the database
|
||||
# all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this parent _path
|
||||
def __init__(self, dbfile, export_dir):
|
||||
self._dbfile = dbfile or f"./{OSXPHOTOS_EXPORT_DB}"
|
||||
# export_dir is required as all files referenced by get_/set_uuid_for_file will be converted to
|
||||
# relative paths to this path
|
||||
# this allows the entire export tree to be moved to a new disk/location
|
||||
# whilst preserving the UUID to filename mappping
|
||||
self._path = pathlib.Path(dbfile).parent
|
||||
self._conn = self._open_export_db(dbfile)
|
||||
# whilst preserving the UUID to filename mapping
|
||||
self._path = export_dir
|
||||
self._conn = self._open_export_db(self._dbfile)
|
||||
self._insert_run_info()
|
||||
|
||||
def _open_export_db(self, dbfile):
|
||||
""" open export database and return a db connection
|
||||
returns: connection to the database
|
||||
"""open export database and return a db connection
|
||||
returns: connection to the database
|
||||
"""
|
||||
if not os.path.isfile(dbfile):
|
||||
conn = self._get_db_connection()
|
||||
if conn:
|
||||
self._create_db_tables(conn)
|
||||
self.was_created = True
|
||||
self.was_upgraded = ()
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
else:
|
||||
if not conn:
|
||||
raise Exception("Error getting connection to in-memory database")
|
||||
self._create_db_tables(conn)
|
||||
self.was_created = True
|
||||
self.was_upgraded = ()
|
||||
else:
|
||||
try:
|
||||
conn = sqlite3.connect(dbfile)
|
||||
@@ -749,12 +887,11 @@ class ExportDBInMemory(ExportDB):
|
||||
self.was_upgraded = (exportdb_ver, OSXPHOTOS_EXPORTDB_VERSION)
|
||||
else:
|
||||
self.was_upgraded = ()
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
|
||||
self.version = OSXPHOTOS_EXPORTDB_VERSION
|
||||
return conn
|
||||
|
||||
def _get_db_connection(self):
|
||||
""" return db connection to in memory database """
|
||||
"""return db connection to in memory database"""
|
||||
try:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
except Error as e:
|
||||
|
||||
@@ -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,7 +142,7 @@ class FileUtilMacOS(FileUtilABC):
|
||||
|
||||
@classmethod
|
||||
def utime(cls, path, times):
|
||||
""" Set the access and modified time of path. """
|
||||
"""Set the access and modified time of path."""
|
||||
os.utime(path, times)
|
||||
|
||||
@classmethod
|
||||
@@ -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"""
|
||||
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
|
||||
@@ -1,27 +1,38 @@
|
||||
""" utility functions for validating/sanitizing path components """
|
||||
|
||||
import re
|
||||
|
||||
import pathvalidate
|
||||
|
||||
from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
|
||||
|
||||
__all__ = [
|
||||
"is_valid_filepath",
|
||||
"sanitize_dirname",
|
||||
"sanitize_filename",
|
||||
"sanitize_filepath",
|
||||
"sanitize_filestem_with_count",
|
||||
"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
|
||||
"""
|
||||
@@ -45,13 +56,33 @@ def sanitize_filename(filename, replacement=":"):
|
||||
return filename
|
||||
|
||||
|
||||
def sanitize_filestem_with_count(file_stem: str, file_suffix: str) -> str:
|
||||
"""Sanitize a filestem that may end in (1), (2), etc. to ensure it + file_suffix doesn't exceed MAX_FILENAME_LEN"""
|
||||
filename_len = len(file_stem) + len(file_suffix)
|
||||
if filename_len <= MAX_FILENAME_LEN:
|
||||
return file_stem
|
||||
|
||||
drop = filename_len - MAX_FILENAME_LEN
|
||||
match = re.match(r"(.*)(\(\d+\))$", file_stem)
|
||||
if not match:
|
||||
# filename doesn't end in (1), (2), etc.
|
||||
# truncate filename to MAX_FILENAME_LEN
|
||||
return file_stem[:-drop]
|
||||
|
||||
# filename ends in (1), (2), etc.
|
||||
file_stem = match.group(1)
|
||||
file_count = match.group(2)
|
||||
file_stem = file_stem[:-drop]
|
||||
return f"{file_stem}{file_count}"
|
||||
|
||||
|
||||
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 +92,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):
|
||||
|
||||
2020
osxphotos/photoexporter.py
Normal file
2020
osxphotos/photoexporter.py
Normal file
File diff suppressed because it is too large
Load Diff
1761
osxphotos/photoinfo.py
Normal file
1761
osxphotos/photoinfo.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
PhotoInfo class
|
||||
Represents a single photo in the Photos library and provides access to the photo's attributes
|
||||
PhotosDB.photos() returns a list of PhotoInfo objects
|
||||
"""
|
||||
|
||||
from ._photoinfo_exifinfo import ExifInfo
|
||||
from ._photoinfo_export import ExportResults
|
||||
from ._photoinfo_scoreinfo import ScoreInfo
|
||||
from .photoinfo import PhotoInfo
|
||||
@@ -1,17 +0,0 @@
|
||||
""" PhotoInfo methods to expose comments and likes for shared photos """
|
||||
|
||||
@property
|
||||
def comments(self):
|
||||
""" Returns list of Comment objects for any comments on the photo (sorted by date) """
|
||||
try:
|
||||
return self._db._db_comments_uuid[self.uuid]["comments"]
|
||||
except:
|
||||
return []
|
||||
|
||||
@property
|
||||
def likes(self):
|
||||
""" Returns list of Like objects for any likes on the photo (sorted by date) """
|
||||
try:
|
||||
return self._db._db_comments_uuid[self.uuid]["likes"]
|
||||
except:
|
||||
return []
|
||||
@@ -1,94 +0,0 @@
|
||||
""" PhotoInfo methods to expose EXIF info from the library """
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExifInfo:
|
||||
""" EXIF info associated with a photo from the Photos library """
|
||||
|
||||
flash_fired: bool
|
||||
iso: int
|
||||
metering_mode: int
|
||||
sample_rate: int
|
||||
track_format: int
|
||||
white_balance: int
|
||||
aperture: float
|
||||
bit_rate: float
|
||||
duration: float
|
||||
exposure_bias: float
|
||||
focal_length: float
|
||||
fps: float
|
||||
latitude: float
|
||||
longitude: float
|
||||
shutter_speed: float
|
||||
camera_make: str
|
||||
camera_model: str
|
||||
codec: str
|
||||
lens_model: str
|
||||
|
||||
|
||||
@property
|
||||
def exif_info(self):
|
||||
""" Returns an ExifInfo object with the EXIF data for photo
|
||||
Note: the returned EXIF data is the data Photos stores in the database on import;
|
||||
ExifInfo does not provide access to the EXIF info in the actual image file
|
||||
Some or all of the fields may be None
|
||||
Only valid for Photos 5; on earlier database returns None
|
||||
"""
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.debug(f"exif_info not implemented for this database version")
|
||||
return None
|
||||
|
||||
try:
|
||||
exif = self._db._db_exifinfo_uuid[self.uuid]
|
||||
exif_info = ExifInfo(
|
||||
iso=exif["ZISO"],
|
||||
flash_fired=True if exif["ZFLASHFIRED"] == 1 else False,
|
||||
metering_mode=exif["ZMETERINGMODE"],
|
||||
sample_rate=exif["ZSAMPLERATE"],
|
||||
track_format=exif["ZTRACKFORMAT"],
|
||||
white_balance=exif["ZWHITEBALANCE"],
|
||||
aperture=exif["ZAPERTURE"],
|
||||
bit_rate=exif["ZBITRATE"],
|
||||
duration=exif["ZDURATION"],
|
||||
exposure_bias=exif["ZEXPOSUREBIAS"],
|
||||
focal_length=exif["ZFOCALLENGTH"],
|
||||
fps=exif["ZFPS"],
|
||||
latitude=exif["ZLATITUDE"],
|
||||
longitude=exif["ZLONGITUDE"],
|
||||
shutter_speed=exif["ZSHUTTERSPEED"],
|
||||
camera_make=exif["ZCAMERAMAKE"],
|
||||
camera_model=exif["ZCAMERAMODEL"],
|
||||
codec=exif["ZCODEC"],
|
||||
lens_model=exif["ZLENSMODEL"],
|
||||
)
|
||||
except KeyError:
|
||||
logging.debug(f"Could not find exif record for uuid {self.uuid}")
|
||||
exif_info = ExifInfo(
|
||||
iso=None,
|
||||
flash_fired=None,
|
||||
metering_mode=None,
|
||||
sample_rate=None,
|
||||
track_format=None,
|
||||
white_balance=None,
|
||||
aperture=None,
|
||||
bit_rate=None,
|
||||
duration=None,
|
||||
exposure_bias=None,
|
||||
focal_length=None,
|
||||
fps=None,
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
shutter_speed=None,
|
||||
camera_make=None,
|
||||
camera_model=None,
|
||||
codec=None,
|
||||
lens_model=None,
|
||||
)
|
||||
|
||||
return exif_info
|
||||
@@ -1,34 +0,0 @@
|
||||
""" Implementation for PhotoInfo.exiftool property which returns ExifTool object for a photo """
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..exiftool import ExifTool, get_exiftool_path
|
||||
|
||||
|
||||
@property
|
||||
def exiftool(self):
|
||||
""" Returns an ExifTool object for the photo
|
||||
requires that exiftool (https://exiftool.org/) be installed
|
||||
If exiftool not installed, logs warning and returns None
|
||||
If photo path is missing, returns None
|
||||
"""
|
||||
try:
|
||||
# return the memoized instance if it exists
|
||||
return self._exiftool
|
||||
except AttributeError:
|
||||
try:
|
||||
exiftool_path = self._db._exiftool_path or get_exiftool_path()
|
||||
if self.path is not None and os.path.isfile(self.path):
|
||||
exiftool = ExifTool(self.path, exiftool=exiftool_path)
|
||||
else:
|
||||
exiftool = None
|
||||
except FileNotFoundError:
|
||||
# get_exiftool_path raises FileNotFoundError if exiftool not found
|
||||
exiftool = None
|
||||
logging.warning(
|
||||
f"exiftool not in path; download and install from https://exiftool.org/"
|
||||
)
|
||||
|
||||
self._exiftool = exiftool
|
||||
return self._exiftool
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,119 +0,0 @@
|
||||
""" PhotoInfo methods to expose computed score info from the library """
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .._constants import _PHOTOS_4_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreInfo:
|
||||
""" Computed photo score info associated with a photo from the Photos library """
|
||||
|
||||
overall: float
|
||||
curation: float
|
||||
promotion: float
|
||||
highlight_visibility: float
|
||||
behavioral: float
|
||||
failure: float
|
||||
harmonious_color: float
|
||||
immersiveness: float
|
||||
interaction: float
|
||||
interesting_subject: float
|
||||
intrusive_object_presence: float
|
||||
lively_color: float
|
||||
low_light: float
|
||||
noise: float
|
||||
pleasant_camera_tilt: float
|
||||
pleasant_composition: float
|
||||
pleasant_lighting: float
|
||||
pleasant_pattern: float
|
||||
pleasant_perspective: float
|
||||
pleasant_post_processing: float
|
||||
pleasant_reflection: float
|
||||
pleasant_symmetry: float
|
||||
sharply_focused_subject: float
|
||||
tastefully_blurred: float
|
||||
well_chosen_subject: float
|
||||
well_framed_subject: float
|
||||
well_timed_shot: float
|
||||
|
||||
|
||||
@property
|
||||
def score(self):
|
||||
""" Computed score information for a photo
|
||||
|
||||
Returns:
|
||||
ScoreInfo instance
|
||||
"""
|
||||
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.debug(f"score not implemented for this database version")
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._scoreinfo # pylint: disable=access-member-before-definition
|
||||
except AttributeError:
|
||||
try:
|
||||
scores = self._db._db_scoreinfo_uuid[self.uuid]
|
||||
self._scoreinfo = ScoreInfo(
|
||||
overall=scores["overall_aesthetic"],
|
||||
curation=scores["curation"],
|
||||
promotion=scores["promotion"],
|
||||
highlight_visibility=scores["highlight_visibility"],
|
||||
behavioral=scores["behavioral"],
|
||||
failure=scores["failure"],
|
||||
harmonious_color=scores["harmonious_color"],
|
||||
immersiveness=scores["immersiveness"],
|
||||
interaction=scores["interaction"],
|
||||
interesting_subject=scores["interesting_subject"],
|
||||
intrusive_object_presence=scores["intrusive_object_presence"],
|
||||
lively_color=scores["lively_color"],
|
||||
low_light=scores["low_light"],
|
||||
noise=scores["noise"],
|
||||
pleasant_camera_tilt=scores["pleasant_camera_tilt"],
|
||||
pleasant_composition=scores["pleasant_composition"],
|
||||
pleasant_lighting=scores["pleasant_lighting"],
|
||||
pleasant_pattern=scores["pleasant_pattern"],
|
||||
pleasant_perspective=scores["pleasant_perspective"],
|
||||
pleasant_post_processing=scores["pleasant_post_processing"],
|
||||
pleasant_reflection=scores["pleasant_reflection"],
|
||||
pleasant_symmetry=scores["pleasant_symmetry"],
|
||||
sharply_focused_subject=scores["sharply_focused_subject"],
|
||||
tastefully_blurred=scores["tastefully_blurred"],
|
||||
well_chosen_subject=scores["well_chosen_subject"],
|
||||
well_framed_subject=scores["well_framed_subject"],
|
||||
well_timed_shot=scores["well_timed_shot"],
|
||||
)
|
||||
return self._scoreinfo
|
||||
except KeyError:
|
||||
self._scoreinfo = ScoreInfo(
|
||||
overall=0.0,
|
||||
curation=0.0,
|
||||
promotion=0.0,
|
||||
highlight_visibility=0.0,
|
||||
behavioral=0.0,
|
||||
failure=0.0,
|
||||
harmonious_color=0.0,
|
||||
immersiveness=0.0,
|
||||
interaction=0.0,
|
||||
interesting_subject=0.0,
|
||||
intrusive_object_presence=0.0,
|
||||
lively_color=0.0,
|
||||
low_light=0.0,
|
||||
noise=0.0,
|
||||
pleasant_camera_tilt=0.0,
|
||||
pleasant_composition=0.0,
|
||||
pleasant_lighting=0.0,
|
||||
pleasant_pattern=0.0,
|
||||
pleasant_perspective=0.0,
|
||||
pleasant_post_processing=0.0,
|
||||
pleasant_reflection=0.0,
|
||||
pleasant_symmetry=0.0,
|
||||
sharply_focused_subject=0.0,
|
||||
tastefully_blurred=0.0,
|
||||
well_chosen_subject=0.0,
|
||||
well_framed_subject=0.0,
|
||||
well_timed_shot=0.0,
|
||||
)
|
||||
return self._scoreinfo
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
||||
""" PhotosAlbum class to create an album in default Photos library and add photos to it """
|
||||
|
||||
from typing import Optional, List
|
||||
from typing import List, Optional
|
||||
|
||||
import photoscript
|
||||
from more_itertools import chunked
|
||||
|
||||
from .photoinfo import PhotoInfo
|
||||
from .utils import noop
|
||||
|
||||
__all__ = ["PhotosAlbum"]
|
||||
|
||||
|
||||
class PhotosAlbum:
|
||||
def __init__(self, name: str, verbose: Optional[callable] = None):
|
||||
@@ -26,8 +31,14 @@ class PhotosAlbum:
|
||||
)
|
||||
|
||||
def add_list(self, photo_list: List[PhotoInfo]):
|
||||
photos = [photoscript.Photo(p.uuid) for p in photo_list]
|
||||
self.album.add(photos)
|
||||
photos = []
|
||||
for p in photo_list:
|
||||
try:
|
||||
photos.append(photoscript.Photo(p.uuid))
|
||||
except Exception as e:
|
||||
self.verbose(f"Error creating Photo object for photo {p.uuid}: {e}")
|
||||
for photolist in chunked(photos, 10):
|
||||
self.album.add(photolist)
|
||||
photo_len = len(photos)
|
||||
photo_word = "photos" if photo_len > 1 else "photo"
|
||||
self.verbose(f"Added {photo_len} {photo_word} to album {self.name}")
|
||||
|
||||
@@ -10,9 +10,9 @@ from ..utils import _open_sql_file, normalize_unicode
|
||||
|
||||
|
||||
def _process_comments(self):
|
||||
""" load the comments and likes data from the database
|
||||
this is a PhotosDB method that should be imported in
|
||||
the PhotosDB class definition in photosdb.py
|
||||
"""load the comments and likes data from the database
|
||||
this is a PhotosDB method that should be imported in
|
||||
the PhotosDB class definition in photosdb.py
|
||||
"""
|
||||
self._db_hashed_person_id = {}
|
||||
self._db_comments_uuid = {}
|
||||
@@ -24,7 +24,7 @@ def _process_comments(self):
|
||||
|
||||
@dataclass
|
||||
class CommentInfo:
|
||||
""" Class for shared photo comments """
|
||||
"""Class for shared photo comments"""
|
||||
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
@@ -37,7 +37,7 @@ class CommentInfo:
|
||||
|
||||
@dataclass
|
||||
class LikeInfo:
|
||||
""" Class for shared photo likes """
|
||||
"""Class for shared photo likes"""
|
||||
|
||||
datetime: datetime.datetime
|
||||
user: str
|
||||
@@ -50,16 +50,16 @@ class LikeInfo:
|
||||
# The following methods do not get imported into PhotosDB
|
||||
# but will get called by _process_comments
|
||||
def _process_comments_4(photosdb):
|
||||
""" process comments and likes info for Photos <= 4
|
||||
photosdb: PhotosDB instance """
|
||||
"""process comments and likes info for Photos <= 4
|
||||
photosdb: PhotosDB instance"""
|
||||
raise NotImplementedError(
|
||||
f"Not implemented for database version {photosdb._db_version}."
|
||||
)
|
||||
|
||||
|
||||
def _process_comments_5(photosdb):
|
||||
""" process comments and likes info for Photos >= 5
|
||||
photosdb: PhotosDB instance """
|
||||
"""process comments and likes info for Photos >= 5
|
||||
photosdb: PhotosDB instance"""
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
@@ -70,12 +70,24 @@ def _process_comments_5(photosdb):
|
||||
results = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
ZINVITEEHASHEDPERSONID,
|
||||
ZINVITEEFIRSTNAME,
|
||||
ZINVITEELASTNAME,
|
||||
ZINVITEEFULLNAME
|
||||
FROM
|
||||
ZCLOUDSHAREDALBUMINVITATIONRECORD
|
||||
ZINVITEEHASHEDPERSONID AS HASHEDPERSONID,
|
||||
ZINVITEEFIRSTNAME AS FIRSTNAME,
|
||||
ZINVITEELASTNAME AS LASTNAME,
|
||||
ZINVITEEFULLNAME AS FULLNAME
|
||||
FROM ZCLOUDSHAREDALBUMINVITATIONRECORD
|
||||
WHERE HASHEDPERSONID IS NOT NULL
|
||||
AND HASHEDPERSONID != ""
|
||||
AND NOT (FIRSTNAME IS NULL AND LASTNAME IS NULL)
|
||||
UNION
|
||||
SELECT DISTINCT
|
||||
ZCLOUDOWNERHASHEDPERSONID AS HASHEDPERSONID,
|
||||
ZCLOUDOWNERFIRSTNAME AS FIRSTNAME,
|
||||
ZCLOUDOWNERLASTNAME AS LASTNAME,
|
||||
ZCLOUDOWNERFULLNAME AS FULLNAME
|
||||
FROM ZGENERICALBUM
|
||||
WHERE HASHEDPERSONID IS NOT NULL
|
||||
AND HASHEDPERSONID != ""
|
||||
AND NOT (FIRSTNAME IS NULL AND LASTNAME IS NULL)
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -148,10 +160,10 @@ def _process_comments_5(photosdb):
|
||||
db_comments["comments"].append(CommentInfo(dt, user_name, ismine, text))
|
||||
|
||||
# sort results
|
||||
for uuid in photosdb._db_comments_uuid:
|
||||
for uuid, value in photosdb._db_comments_uuid.items():
|
||||
if photosdb._db_comments_uuid[uuid]["likes"]:
|
||||
photosdb._db_comments_uuid[uuid]["likes"].sort(key=lambda x: x.datetime)
|
||||
if photosdb._db_comments_uuid[uuid]["comments"]:
|
||||
photosdb._db_comments_uuid[uuid]["comments"].sort(key=lambda x: x.datetime)
|
||||
value["comments"].sort(key=lambda x: x.datetime)
|
||||
|
||||
conn.close()
|
||||
|
||||
@@ -7,10 +7,11 @@ from .._constants import _DB_TABLE_NAMES, _PHOTOS_4_VERSION
|
||||
from ..utils import _db_is_locked, _debug, _open_sql_file
|
||||
from .photosdb_utils import get_db_version
|
||||
|
||||
|
||||
def _process_exifinfo(self):
|
||||
""" load the exif data from the database
|
||||
this is a PhotosDB method that should be imported in
|
||||
the PhotosDB class definition in photosdb.py
|
||||
"""load the exif data from the database
|
||||
this is a PhotosDB method that should be imported in
|
||||
the PhotosDB class definition in photosdb.py
|
||||
"""
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
_process_exifinfo_4(self)
|
||||
@@ -23,20 +24,20 @@ def _process_exifinfo(self):
|
||||
|
||||
|
||||
def _process_exifinfo_4(photosdb):
|
||||
""" process exif info for Photos <= 4
|
||||
photosdb: PhotosDB instance """
|
||||
"""process exif info for Photos <= 4
|
||||
photosdb: PhotosDB instance"""
|
||||
photosdb._db_exifinfo_uuid = {}
|
||||
raise NotImplementedError(f"search info not implemented for this database version")
|
||||
|
||||
|
||||
def _process_exifinfo_5(photosdb):
|
||||
""" process exif info for Photos >= 5
|
||||
photosdb: PhotosDB instance """
|
||||
"""process exif info for Photos >= 5
|
||||
photosdb: PhotosDB instance"""
|
||||
|
||||
db = photosdb._tmp_db
|
||||
|
||||
asset_table = _DB_TABLE_NAMES[photosdb._photos_ver]["ASSET"]
|
||||
|
||||
|
||||
(conn, cursor) = _open_sql_file(db)
|
||||
|
||||
result = conn.execute(
|
||||
|
||||
@@ -22,8 +22,7 @@ from .photosdb_utils import get_db_version
|
||||
|
||||
|
||||
def _process_faceinfo(self):
|
||||
""" Process face information
|
||||
"""
|
||||
"""Process face information"""
|
||||
|
||||
self._db_faceinfo_pk = {}
|
||||
self._db_faceinfo_uuid = {}
|
||||
@@ -36,7 +35,7 @@ def _process_faceinfo(self):
|
||||
|
||||
|
||||
def _process_faceinfo_4(photosdb):
|
||||
""" Process face information for Photos 4 databases
|
||||
"""Process face information for Photos 4 databases
|
||||
|
||||
Args:
|
||||
photosdb: an OSXPhotosDB instance
|
||||
@@ -146,7 +145,6 @@ def _process_faceinfo_4(photosdb):
|
||||
|
||||
# Photos 5 only
|
||||
face["agetype"] = None
|
||||
face["baldtype"] = None
|
||||
face["eyemakeuptype"] = None
|
||||
face["eyestate"] = None
|
||||
face["facialhairtype"] = None
|
||||
@@ -173,7 +171,7 @@ def _process_faceinfo_4(photosdb):
|
||||
|
||||
|
||||
def _process_faceinfo_5(photosdb):
|
||||
""" Process face information for Photos 5 databases
|
||||
"""Process face information for Photos 5 databases
|
||||
|
||||
Args:
|
||||
photosdb: an OSXPhotosDB instance
|
||||
@@ -194,7 +192,7 @@ def _process_faceinfo_5(photosdb):
|
||||
ZDETECTEDFACE.ZPERSON,
|
||||
ZPERSON.ZFULLNAME,
|
||||
ZDETECTEDFACE.ZAGETYPE,
|
||||
ZDETECTEDFACE.ZBALDTYPE,
|
||||
NULL, -- ZDETECTEDFACE.ZBALDTYPE (Removed in Monterey)
|
||||
ZDETECTEDFACE.ZEYEMAKEUPTYPE,
|
||||
ZDETECTEDFACE.ZEYESSTATE,
|
||||
ZDETECTEDFACE.ZFACIALHAIRTYPE,
|
||||
@@ -239,7 +237,7 @@ def _process_faceinfo_5(photosdb):
|
||||
# 3 ZDETECTEDFACE.ZPERSON,
|
||||
# 4 ZPERSON.ZFULLNAME,
|
||||
# 5 ZDETECTEDFACE.ZAGETYPE,
|
||||
# 6 ZDETECTEDFACE.ZBALDTYPE,
|
||||
# 6 ZDETECTEDFACE.ZBALDTYPE, (Not available on Monterey)
|
||||
# 7 ZDETECTEDFACE.ZEYEMAKEUPTYPE,
|
||||
# 8 ZDETECTEDFACE.ZEYESSTATE,
|
||||
# 9 ZDETECTEDFACE.ZFACIALHAIRTYPE,
|
||||
@@ -284,7 +282,6 @@ def _process_faceinfo_5(photosdb):
|
||||
face["person"] = person_pk
|
||||
face["fullname"] = normalize_unicode(row[4])
|
||||
face["agetype"] = row[5]
|
||||
face["baldtype"] = row[6]
|
||||
face["eyemakeuptype"] = row[7]
|
||||
face["eyestate"] = row[8]
|
||||
face["facialhairtype"] = row[9]
|
||||
|
||||
@@ -22,8 +22,8 @@ from .photosdb_utils import get_db_version
|
||||
|
||||
|
||||
def _process_scoreinfo(self):
|
||||
""" Process computed photo scores
|
||||
Note: Only works on Photos version == 5.0
|
||||
"""Process computed photo scores
|
||||
Note: Only works on Photos version == 5.0
|
||||
"""
|
||||
|
||||
# _db_scoreinfo_uuid is dict in form {uuid: {score values}}
|
||||
@@ -38,7 +38,7 @@ def _process_scoreinfo(self):
|
||||
|
||||
|
||||
def _process_scoreinfo_5(photosdb):
|
||||
""" Process computed photo scores for Photos 5 databases
|
||||
"""Process computed photo scores for Photos 5 databases
|
||||
|
||||
Args:
|
||||
photosdb: an OSXPhotosDB instance
|
||||
@@ -147,4 +147,4 @@ def _process_scoreinfo_5(photosdb):
|
||||
scores["well_timed_shot"] = row[27]
|
||||
photosdb._db_scoreinfo_uuid[uuid] = scores
|
||||
|
||||
conn.close()
|
||||
conn.close()
|
||||
|
||||
@@ -35,10 +35,10 @@ from ..utils import _db_is_locked, _debug, _open_sql_file, normalize_unicode
|
||||
|
||||
|
||||
def _process_searchinfo(self):
|
||||
""" load machine learning/search term label info from a Photos library
|
||||
db_connection: a connection to the SQLite database file containing the
|
||||
search terms. In Photos 5, this is called psi.sqlite
|
||||
Note: Only works on Photos version == 5.0 """
|
||||
"""load machine learning/search term label info from a Photos library
|
||||
db_connection: a connection to the SQLite database file containing the
|
||||
search terms. In Photos 5, this is called psi.sqlite
|
||||
Note: Only works on Photos version == 5.0"""
|
||||
|
||||
# _db_searchinfo_uuid is dict in form {uuid : [list of associated search info records]
|
||||
self._db_searchinfo_uuid = _db_searchinfo_uuid = {}
|
||||
@@ -155,7 +155,7 @@ def _process_searchinfo(self):
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" return list of all search info labels found in the library """
|
||||
"""return list of all search info labels found in the library"""
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.warning(f"SearchInfo not implemented for this library version")
|
||||
return []
|
||||
@@ -165,7 +165,7 @@ def labels(self):
|
||||
|
||||
@property
|
||||
def labels_normalized(self):
|
||||
""" return list of all normalized search info labels found in the library """
|
||||
"""return list of all normalized search info labels found in the library"""
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.warning(f"SearchInfo not implemented for this library version")
|
||||
return []
|
||||
@@ -175,7 +175,7 @@ def labels_normalized(self):
|
||||
|
||||
@property
|
||||
def labels_as_dict(self):
|
||||
""" return labels as dict of label: count in reverse sorted order (descending) """
|
||||
"""return labels as dict of label: count in reverse sorted order (descending)"""
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.warning(f"SearchInfo not implemented for this library version")
|
||||
return dict()
|
||||
@@ -187,7 +187,7 @@ def labels_as_dict(self):
|
||||
|
||||
@property
|
||||
def labels_normalized_as_dict(self):
|
||||
""" return normalized labels as dict of label: count in reverse sorted order (descending) """
|
||||
"""return normalized labels as dict of label: count in reverse sorted order (descending)"""
|
||||
if self._db_version <= _PHOTOS_4_VERSION:
|
||||
logging.warning(f"SearchInfo not implemented for this library version")
|
||||
return dict()
|
||||
@@ -201,8 +201,8 @@ def labels_normalized_as_dict(self):
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def ints_to_uuid(uuid_0, uuid_1):
|
||||
""" convert two signed ints into a UUID strings
|
||||
uuid_0, uuid_1: the two int components of an RFC 4122 UUID """
|
||||
"""convert two signed ints into a UUID strings
|
||||
uuid_0, uuid_1: the two int components of an RFC 4122 UUID"""
|
||||
|
||||
# assumes uuid imported as uuidlib (to avoid namespace conflict with other uses of uuid)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,32 @@
|
||||
""" utility functions used by PhotosDB """
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import plistlib
|
||||
|
||||
from .._constants import (
|
||||
_PHOTOS_2_VERSION,
|
||||
_PHOTOS_3_VERSION,
|
||||
_PHOTOS_4_VERSION,
|
||||
_PHOTOS_5_MODEL_VERSION,
|
||||
_PHOTOS_5_VERSION,
|
||||
_PHOTOS_6_MODEL_VERSION,
|
||||
_PHOTOS_7_MODEL_VERSION,
|
||||
_TESTED_DB_VERSIONS,
|
||||
)
|
||||
from ..utils import _open_sql_file
|
||||
|
||||
__all__ = [
|
||||
"get_db_version",
|
||||
"get_model_version",
|
||||
"get_db_model_version",
|
||||
"UnknownLibraryVersion",
|
||||
"get_photos_library_version",
|
||||
]
|
||||
|
||||
|
||||
def get_db_version(db_file):
|
||||
""" Gets the Photos DB version from LiGlobals table
|
||||
"""Gets the Photos DB version from LiGlobals table
|
||||
|
||||
Args:
|
||||
db_file: path to photos.db database file containing LiGlobals table
|
||||
@@ -39,11 +53,11 @@ def get_db_version(db_file):
|
||||
|
||||
|
||||
def get_model_version(db_file):
|
||||
""" Returns the database model version from Z_METADATA
|
||||
|
||||
"""Returns the database model version from Z_METADATA
|
||||
|
||||
Args:
|
||||
db_file: path to Photos.sqlite database file containing Z_METADATA table
|
||||
|
||||
|
||||
Returns: model version as str
|
||||
"""
|
||||
|
||||
@@ -62,23 +76,51 @@ def get_model_version(db_file):
|
||||
|
||||
|
||||
def get_db_model_version(db_file):
|
||||
""" Returns Photos version based on model version found in db_file
|
||||
|
||||
"""Returns Photos version based on model version found in db_file
|
||||
|
||||
Args:
|
||||
db_file: path to Photos.sqlite file
|
||||
|
||||
|
||||
Returns: int of major Photos version number (e.g. 5 or 6).
|
||||
If unknown model version found, logs warning and returns most current Photos version.
|
||||
"""
|
||||
|
||||
model_ver = get_model_version(db_file)
|
||||
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:
|
||||
db_ver = 5
|
||||
return 5
|
||||
elif _PHOTOS_6_MODEL_VERSION[0] <= model_ver <= _PHOTOS_6_MODEL_VERSION[1]:
|
||||
db_ver = 6
|
||||
return 6
|
||||
elif _PHOTOS_7_MODEL_VERSION[0] <= model_ver <= _PHOTOS_7_MODEL_VERSION[1]:
|
||||
return 7
|
||||
else:
|
||||
logging.warning(f"Unknown model version: {model_ver}")
|
||||
# cross our fingers and try latest version
|
||||
db_ver = 6
|
||||
return 7
|
||||
|
||||
return db_ver
|
||||
|
||||
class UnknownLibraryVersion(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_photos_library_version(library_path):
|
||||
"""Return int indicating which Photos version a library was created with"""
|
||||
library_path = pathlib.Path(library_path)
|
||||
db_ver = get_db_version(str(library_path / "database" / "photos.db"))
|
||||
db_ver = int(db_ver)
|
||||
if db_ver == int(_PHOTOS_2_VERSION):
|
||||
return 2
|
||||
if db_ver == int(_PHOTOS_3_VERSION):
|
||||
return 3
|
||||
if db_ver == int(_PHOTOS_4_VERSION):
|
||||
return 4
|
||||
|
||||
# assume it's a Photos 5+ library, get the model version to determine which version
|
||||
model_ver = get_model_version(str(library_path / "database" / "Photos.sqlite"))
|
||||
model_ver = int(model_ver)
|
||||
if _PHOTOS_5_MODEL_VERSION[0] <= model_ver <= _PHOTOS_5_MODEL_VERSION[1]:
|
||||
return 5
|
||||
if _PHOTOS_6_MODEL_VERSION[0] <= model_ver <= _PHOTOS_6_MODEL_VERSION[1]:
|
||||
return 6
|
||||
if _PHOTOS_7_MODEL_VERSION[0] <= model_ver <= _PHOTOS_7_MODEL_VERSION[1]:
|
||||
return 7
|
||||
raise UnknownLibraryVersion(f"db_ver = {db_ver}, model_ver = {model_ver}")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
The templating system converts one or template statements, written in osxphotos templating language, to one or more rendered values using information from the photo being processed.
|
||||
The templating system converts one or template statements, written in osxphotos metadata templating language, to one or more rendered values using information from the photo being processed.
|
||||
|
||||
In its simplest form, a template statement has the form: `"{template_field}"`, for example `"{title}"` which would resolve to the title of the photo.
|
||||
|
||||
@@ -39,6 +39,7 @@ Valid filters are:
|
||||
- braces: Enclose value in curly braces, e.g. 'value => '{value}'.
|
||||
- parens: Enclose value in parentheses, e.g. 'value' => '(value')
|
||||
- brackets: Enclose value in brackets, e.g. 'value' => '[value]'
|
||||
- shell_quote: Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.
|
||||
- function: Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py
|
||||
<!-- OSXPHOTOS-FILTER-TABLE:END -->
|
||||
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
""" Custom template system for osxphotos, implements osxphotos template language (OTL) """
|
||||
""" Custom template system for osxphotos, implements metadata template language (MTL) """
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from textx import TextXSyntaxError, metamodel_from_file
|
||||
|
||||
from ._constants import _UNKNOWN_PERSON
|
||||
from ._constants import _UNKNOWN_PERSON, TEXT_DETECTION_CONFIDENCE_THRESHOLD
|
||||
from ._version import __version__
|
||||
from .datetime_formatter import DateTimeFormatter
|
||||
from .exiftool import ExifToolCaching
|
||||
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
|
||||
from .utils import load_function
|
||||
from .text_detection import detect_text
|
||||
from .utils import expand_and_validate_filepath, load_function
|
||||
|
||||
__all__ = [
|
||||
"RenderOptions",
|
||||
"PhotoTemplateParser",
|
||||
"PhotoTemplate",
|
||||
"parse_default_kv",
|
||||
"get_template_help",
|
||||
"format_str_value",
|
||||
]
|
||||
|
||||
# TODO: a lot of values are passed from function to function like path_sep--make these all class properties
|
||||
|
||||
# ensure locale set to user's locale
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
|
||||
OTL_GRAMMAR_MODEL = str(pathlib.Path(__file__).parent / "phototemplate.tx")
|
||||
MTL_GRAMMAR_MODEL = str(pathlib.Path(__file__).parent / "phototemplate.tx")
|
||||
|
||||
"""TextX metamodel for osxphotos template language """
|
||||
|
||||
@@ -120,6 +139,29 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{exif.camera_model}": "Camera model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s'",
|
||||
"{exif.lens_model}": "Lens model from original photo's EXIF information as imported by Photos, e.g. 'iPhone 6s back camera 4.15mm f/2.2'",
|
||||
"{uuid}": "Photo's internal universally unique identifier (UUID) for the photo, a 36-character string unique to the photo, e.g. '128FB4C6-0B16-4E7D-9108-FB2E90DA1546'",
|
||||
"{id}": "A unique number for the photo based on its primary key in the Photos database. "
|
||||
+ "A sequential integer, e.g. 1, 2, 3...etc. Each asset associated with a photo (e.g. an image and Live Photo preview) will share the same id. "
|
||||
+ "May be formatted using a python string format code. "
|
||||
+ "For example, to format as a 5-digit integer and pad with zeros, use '{id:05d}' which results in "
|
||||
+ "00001, 00002, 00003...etc. ",
|
||||
"{album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album. "
|
||||
+ "Only valid when used in a '--filename' template and only when '{album}' or '{folder_album}' is used in the '--directory' template. "
|
||||
+ 'For example \'--directory "{folder_album}" --filename "{album_seq}_{original_name}"\'. '
|
||||
+ "To start counting at a value other than 0, append append a period and the starting value to the field name. "
|
||||
+ "For example, to start counting at 1 instead of 0: '{album_seq.1}'. "
|
||||
+ "May be formatted using a python string format code. "
|
||||
+ "For example, to format as a 5-digit integer and pad with zeros, use '{album_seq:05d}' which results in "
|
||||
+ "00000, 00001, 00002...etc. "
|
||||
+ "This may result in incorrect sequences if you have duplicate albums with the same name; see also '{folder_album_seq}'.",
|
||||
"{folder_album_seq}": "An integer, starting at 0, indicating the photo's index (sequence) in the containing album and folder path. "
|
||||
+ "Only valid when used in a '--filename' template and only when '{folder_album}' is used in the '--directory' template. "
|
||||
+ 'For example \'--directory "{folder_album}" --filename "{folder_album_seq}_{original_name}"\'. '
|
||||
+ "To start counting at a value other than 0, append append a period and the starting value to the field name. "
|
||||
+ "For example, to start counting at 1 instead of 0: '{folder_album_seq.1}' "
|
||||
+ "May be formatted using a python string format code. "
|
||||
+ "For example, to format as a 5-digit integer and pad with zeros, use '{folder_album_seq:05d}' which results in "
|
||||
+ "00000, 00001, 00002...etc. "
|
||||
+ "This may result in incorrect sequences if you have duplicate albums with the same name in the same folder; see also '{album_seq}'.",
|
||||
"{comma}": "A comma: ','",
|
||||
"{semicolon}": "A semicolon: ';'",
|
||||
"{questionmark}": "A question mark: '?'",
|
||||
@@ -134,12 +176,22 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{lf}": r"A line feed: '\n', alias for {newline}",
|
||||
"{cr}": r"A carriage return: '\r'",
|
||||
"{crlf}": r"a carriage return + line feed: '\r\n'",
|
||||
"{osxphotos_version}": f"The osxphotos version, e.g. '{__version__}'",
|
||||
"{osxphotos_cmd_line}": "The full command line used to run osxphotos",
|
||||
}
|
||||
|
||||
TEMPLATE_SUBSTITUTIONS_PATHLIB = {
|
||||
"{export_dir}": "The full path to the export directory",
|
||||
"{filepath}": "The full path to the exported file",
|
||||
}
|
||||
|
||||
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
|
||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
"{album}": "Album(s) photo is contained in",
|
||||
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
|
||||
"{project}": "Project(s) photo is contained in (such as greeting cards, calendars, slideshows)",
|
||||
"{album_project}": "Album(s) and project(s) photo is contained in; treats projects as regular albums",
|
||||
"{folder_album_project}": "Folder path + album (includes projects as albums) photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
|
||||
"{keyword}": "Keyword(s) assigned to photo",
|
||||
"{person}": "Person(s) / face(s) in a photo",
|
||||
"{label}": "Image categorization label associated with a photo (Photos 5+ only). "
|
||||
@@ -160,6 +212,15 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
+ "For example: '{photo.favorite}' is the same as '{favorite}' and '{photo.place.name}' is the same as '{place.name}'. "
|
||||
+ "'{photo}' provides access to properties that are not available as separate template fields but it assumes some knowledge of "
|
||||
+ "the underlying PhotoInfo class. See https://rhettbull.github.io/osxphotos/ for additional documentation on the PhotoInfo class.",
|
||||
"{detected_text}": "List of text strings found in the image after performing text detection. "
|
||||
+ "Using '{detected_text}' will cause osxphotos to perform text detection on your photos using the built-in macOS text detection algorithms which will slow down your export. "
|
||||
+ "The results for each photo will be cached in the export database so that future exports with '--update' do not need to reprocess each photo. "
|
||||
+ "You may pass a confidence threshold value between 0.0 and 1.0 after a colon as in '{detected_text:0.5}'; "
|
||||
+ f"The default confidence threshold is {TEXT_DETECTION_CONFIDENCE_THRESHOLD}. "
|
||||
+ "'{detected_text}' works only on macOS Catalina (10.15) or later. "
|
||||
+ "Note: this feature is not the same thing as Live Text in macOS Monterey, which osxphotos does not yet support.",
|
||||
"{shell_quote}": "Use in form '{shell_quote,TEMPLATE}'; quotes the rendered TEMPLATE value(s) for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.",
|
||||
"{strip}": "Use in form '{strip,TEMPLATE}'; strips whitespace from begining and end of rendered TEMPLATE value(s).",
|
||||
"{function}": "Execute a python function from an external file and use return value as template substitution. "
|
||||
+ "Use in format: {function:file.py::function_name} where 'file.py' is the name of the python file and 'function_name' is the name of the function to call. "
|
||||
+ "The function will be passed the PhotoInfo object for the photo. "
|
||||
@@ -175,7 +236,8 @@ FILTER_VALUES = {
|
||||
"braces": "Enclose value in curly braces, e.g. 'value => '{value}'.",
|
||||
"parens": "Enclose value in parentheses, e.g. 'value' => '(value')",
|
||||
"brackets": "Enclose value in brackets, e.g. 'value' => '[value]'",
|
||||
"function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py"
|
||||
"shell_quote": "Quotes the value for safe usage in the shell, e.g. My file.jpeg => 'My file.jpeg'; only adds quotes if needed.",
|
||||
"function": "Run custom python function to filter value; use in format 'function:/path/to/file.py::function_name'. See example at https://github.com/RhetTbull/osxphotos/blob/master/examples/template_filter.py",
|
||||
}
|
||||
|
||||
# Just the substitutions without the braces
|
||||
@@ -183,13 +245,18 @@ SINGLE_VALUE_SUBSTITUTIONS = [
|
||||
field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS
|
||||
]
|
||||
|
||||
# Just the multi-valued substitution names without the braces
|
||||
PATHLIB_SUBSTITUTIONS = [
|
||||
field.replace("{", "").replace("}", "") for field in TEMPLATE_SUBSTITUTIONS_PATHLIB
|
||||
]
|
||||
|
||||
MULTI_VALUE_SUBSTITUTIONS = [
|
||||
field.replace("{", "").replace("}", "")
|
||||
for field in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
|
||||
]
|
||||
|
||||
FIELD_NAMES = SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS
|
||||
FIELD_NAMES = (
|
||||
SINGLE_VALUE_SUBSTITUTIONS + MULTI_VALUE_SUBSTITUTIONS + PATHLIB_SUBSTITUTIONS
|
||||
)
|
||||
|
||||
# default values for string manipulation template options
|
||||
INPLACE_DEFAULT = ","
|
||||
@@ -213,36 +280,76 @@ PUNCTUATION = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderOptions:
|
||||
"""Options for PhotoTemplate.render
|
||||
|
||||
template: str template
|
||||
none_str: str to use default for None values, default is '_'
|
||||
path_sep: optional string to use as path separator, default is os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
strip: if True, strips leading/trailing whitespace from rendered templates
|
||||
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
|
||||
export_dir: set to the export directory if you want to evalute {export_dir} template
|
||||
dest_path: set to the destination path of the photo (for use by {function} template), only valid with --filename
|
||||
filepath: set to value for filepath of the exported photo if you want to evaluate {filepath} template
|
||||
quote: quote path templates for execution in the shell
|
||||
"""
|
||||
|
||||
none_str: str = "_"
|
||||
path_sep: Optional[str] = PATH_SEP_DEFAULT
|
||||
expand_inplace: bool = False
|
||||
inplace_sep: Optional[str] = INPLACE_DEFAULT
|
||||
filename: bool = False
|
||||
dirname: bool = False
|
||||
strip: bool = False
|
||||
edited_version: bool = False
|
||||
export_dir: Optional[str] = None
|
||||
dest_path: Optional[str] = None
|
||||
filepath: Optional[str] = None
|
||||
quote: bool = False
|
||||
|
||||
|
||||
class PhotoTemplateParser:
|
||||
"""Parser for PhotoTemplate """
|
||||
"""Parser for PhotoTemplate"""
|
||||
|
||||
# implemented as Singleton
|
||||
|
||||
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, "metamodel"):
|
||||
return
|
||||
|
||||
self.metamodel = metamodel_from_file(OTL_GRAMMAR_MODEL, skipws=False)
|
||||
self.metamodel = metamodel_from_file(MTL_GRAMMAR_MODEL, skipws=False)
|
||||
|
||||
def parse(self, template_statement):
|
||||
"""Parse a template_statement string """
|
||||
"""Parse a template_statement string"""
|
||||
return self.metamodel.model_from_str(template_statement)
|
||||
|
||||
def fields(self, template_statement):
|
||||
"""Return list of fields found in a template statement; does not verify that fields are valid"""
|
||||
model = self.parse(template_statement)
|
||||
return [ts.template.field for ts in model.template_strings if ts.template]
|
||||
|
||||
|
||||
class PhotoTemplate:
|
||||
""" PhotoTemplate class to render a template string from a PhotoInfo object """
|
||||
"""PhotoTemplate class to render a template string from a PhotoInfo object"""
|
||||
|
||||
def __init__(self, photo, exiftool_path=None):
|
||||
""" Inits PhotoTemplate class with photo
|
||||
"""Inits PhotoTemplate class with photo
|
||||
|
||||
Args:
|
||||
photo: a PhotoInfo instance.
|
||||
@@ -258,49 +365,56 @@ class PhotoTemplate:
|
||||
# get parser singleton
|
||||
self.parser = PhotoTemplateParser()
|
||||
|
||||
# should {edited_version} render True?
|
||||
self.edited_version = False
|
||||
# initialize render options
|
||||
# this will be done in render() but for testing, some of the lookup functions are called directly
|
||||
options = RenderOptions()
|
||||
self.options = options
|
||||
self.path_sep = options.path_sep
|
||||
self.inplace_sep = options.inplace_sep
|
||||
self.edited_version = options.edited_version
|
||||
self.none_str = options.none_str
|
||||
self.expand_inplace = options.expand_inplace
|
||||
self.filename = options.filename
|
||||
self.dirname = options.dirname
|
||||
self.strip = options.strip
|
||||
self.export_dir = options.export_dir
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
self.dest_path = options.dest_path
|
||||
|
||||
def render(
|
||||
self,
|
||||
template,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
strip=False,
|
||||
edited_version=False,
|
||||
template: str,
|
||||
options: RenderOptions,
|
||||
):
|
||||
""" Render a filename or directory template
|
||||
"""Render a filename or directory template
|
||||
|
||||
Args:
|
||||
template: str template
|
||||
none_str: str to use default for None values, default is '_'
|
||||
path_sep: optional string to use as path separator, default is os.path.sep
|
||||
expand_inplace: expand multi-valued substitutions in-place as a single string
|
||||
instead of returning individual strings
|
||||
inplace_sep: optional string to use as separator between multi-valued keywords
|
||||
with expand_inplace; default is ','
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
strip: if True, strips leading/trailing whitespace from rendered templates
|
||||
edited_version: set to True if you want {edited_version} to resolve to True (e.g. exporting edited version of photo)
|
||||
template: str template
|
||||
options: a RenderOptions instance
|
||||
|
||||
Returns:
|
||||
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
|
||||
"""
|
||||
|
||||
if path_sep is None:
|
||||
path_sep = PATH_SEP_DEFAULT
|
||||
|
||||
if inplace_sep is None:
|
||||
inplace_sep = INPLACE_DEFAULT
|
||||
|
||||
if type(template) is not str:
|
||||
raise TypeError(f"template must be type str, not {type(template)}")
|
||||
|
||||
self.options = options
|
||||
self.path_sep = options.path_sep
|
||||
self.inplace_sep = options.inplace_sep
|
||||
self.edited_version = options.edited_version
|
||||
self.none_str = options.none_str
|
||||
self.expand_inplace = options.expand_inplace
|
||||
self.filename = options.filename
|
||||
self.dirname = options.dirname
|
||||
self.strip = options.strip
|
||||
self.export_dir = options.export_dir
|
||||
self.dest_path = options.dest_path
|
||||
self.filepath = options.filepath
|
||||
self.quote = options.quote
|
||||
self.dest_path = options.dest_path
|
||||
|
||||
try:
|
||||
model = self.parser.parse(template)
|
||||
except TextXSyntaxError as e:
|
||||
@@ -310,53 +424,29 @@ class PhotoTemplate:
|
||||
# empty string
|
||||
return [], []
|
||||
|
||||
self.edited_version = edited_version
|
||||
|
||||
return self._render_statement(
|
||||
model,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
strip=strip,
|
||||
)
|
||||
return self._render_statement(model)
|
||||
|
||||
def _render_statement(
|
||||
self,
|
||||
statement,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
strip=False,
|
||||
):
|
||||
path_sep = path_sep or self.path_sep
|
||||
results = []
|
||||
unmatched = []
|
||||
for ts in statement.template_strings:
|
||||
results, unmatched = self._render_template_string(
|
||||
ts,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
results=results,
|
||||
unmatched=unmatched,
|
||||
ts, results=results, unmatched=unmatched, path_sep=path_sep
|
||||
)
|
||||
|
||||
rendered_strings = results
|
||||
|
||||
if filename:
|
||||
if self.filename:
|
||||
rendered_strings = [
|
||||
sanitize_filename(rendered_str) for rendered_str in rendered_strings
|
||||
]
|
||||
|
||||
if strip:
|
||||
if self.strip:
|
||||
rendered_strings = [
|
||||
rendered_str.strip() for rendered_str in rendered_strings
|
||||
]
|
||||
@@ -366,16 +456,11 @@ class PhotoTemplate:
|
||||
def _render_template_string(
|
||||
self,
|
||||
ts,
|
||||
none_str="_",
|
||||
path_sep=None,
|
||||
expand_inplace=False,
|
||||
inplace_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
path_sep,
|
||||
results=None,
|
||||
unmatched=None,
|
||||
):
|
||||
"""Render a TemplateString object """
|
||||
"""Render a TemplateString object"""
|
||||
|
||||
results = results or [""]
|
||||
unmatched = unmatched or []
|
||||
@@ -383,7 +468,8 @@ class PhotoTemplate:
|
||||
if ts.template:
|
||||
# have a template field to process
|
||||
field = ts.template.field
|
||||
if field not in FIELD_NAMES and not field.startswith("photo"):
|
||||
field_part = field.split(".")[0]
|
||||
if field not in FIELD_NAMES and field_part not in FIELD_NAMES:
|
||||
unmatched.append(field)
|
||||
return [], unmatched
|
||||
|
||||
@@ -410,12 +496,7 @@ class PhotoTemplate:
|
||||
if ts.template.bool.value is not None:
|
||||
bool_val, u = self._render_statement(
|
||||
ts.template.bool.value,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
unmatched.extend(u)
|
||||
else:
|
||||
@@ -431,12 +512,7 @@ class PhotoTemplate:
|
||||
if ts.template.default.value is not None:
|
||||
default, u = self._render_statement(
|
||||
ts.template.default.value,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
unmatched.extend(u)
|
||||
else:
|
||||
@@ -453,12 +529,7 @@ class PhotoTemplate:
|
||||
# conditional value is also a TemplateString
|
||||
conditional_value, u = self._render_statement(
|
||||
ts.template.conditional.value,
|
||||
none_str=none_str,
|
||||
path_sep=path_sep,
|
||||
expand_inplace=expand_inplace,
|
||||
inplace_sep=inplace_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
)
|
||||
unmatched.extend(u)
|
||||
else:
|
||||
@@ -470,14 +541,16 @@ class PhotoTemplate:
|
||||
conditional_value = []
|
||||
|
||||
vals = []
|
||||
if field in SINGLE_VALUE_SUBSTITUTIONS:
|
||||
if (
|
||||
field in SINGLE_VALUE_SUBSTITUTIONS
|
||||
or field.split(".")[0] in SINGLE_VALUE_SUBSTITUTIONS
|
||||
):
|
||||
vals = self.get_template_value(
|
||||
field,
|
||||
default=default,
|
||||
delim=delim or inplace_sep,
|
||||
path_sep=path_sep,
|
||||
filename=filename,
|
||||
dirname=dirname,
|
||||
subfield=subfield,
|
||||
# delim=delim or self.inplace_sep,
|
||||
# path_sep=path_sep,
|
||||
)
|
||||
elif field == "exiftool":
|
||||
if subfield is None:
|
||||
@@ -485,7 +558,7 @@ class PhotoTemplate:
|
||||
"SyntaxError: GROUP:NAME subfield must not be null with {exiftool:GROUP:NAME}'"
|
||||
)
|
||||
vals = self.get_template_value_exiftool(
|
||||
subfield, filename=filename, dirname=dirname
|
||||
subfield,
|
||||
)
|
||||
elif field == "function":
|
||||
if subfield is None:
|
||||
@@ -493,21 +566,23 @@ class PhotoTemplate:
|
||||
"SyntaxError: filename and function must not be null with {function::filename.py:function_name}"
|
||||
)
|
||||
vals = self.get_template_value_function(
|
||||
subfield, filename=filename, dirname=dirname
|
||||
subfield,
|
||||
)
|
||||
elif field in MULTI_VALUE_SUBSTITUTIONS or field.startswith("photo"):
|
||||
vals = self.get_template_value_multi(
|
||||
field, path_sep=path_sep, filename=filename, dirname=dirname
|
||||
field, subfield, path_sep=path_sep, default=default
|
||||
)
|
||||
elif field.split(".")[0] in PATHLIB_SUBSTITUTIONS:
|
||||
vals = self.get_template_value_pathlib(field)
|
||||
else:
|
||||
unmatched.append(field)
|
||||
return [], unmatched
|
||||
|
||||
vals = [val for val in vals if val is not None]
|
||||
|
||||
if expand_inplace or delim is not None:
|
||||
sep = delim if delim is not None else inplace_sep
|
||||
vals = [sep.join(sorted(vals))]
|
||||
if self.expand_inplace or delim is not None:
|
||||
sep = delim if delim is not None else self.inplace_sep
|
||||
vals = [sep.join(sorted(vals))] if vals else []
|
||||
|
||||
for filter_ in filters:
|
||||
vals = self.get_template_value_filter(filter_, vals)
|
||||
@@ -527,7 +602,7 @@ class PhotoTemplate:
|
||||
# have a conditional operator
|
||||
|
||||
def string_test(test_function):
|
||||
""" Perform string comparison using test_function; closure to capture conditional_value, vals, negation """
|
||||
"""Perform string comparison using test_function; closure to capture conditional_value, vals, negation"""
|
||||
match = False
|
||||
for c in conditional_value:
|
||||
for v in vals:
|
||||
@@ -542,18 +617,14 @@ class PhotoTemplate:
|
||||
return []
|
||||
|
||||
def comparison_test(test_function):
|
||||
""" Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation """
|
||||
"""Perform numerical comparisons using test_function; closure to capture conditional_val, vals, negation"""
|
||||
if len(vals) != 1 or len(conditional_value) != 1:
|
||||
raise ValueError(
|
||||
f"comparison operators may only be used with a single value: {vals} {conditional_value}"
|
||||
)
|
||||
try:
|
||||
match = (
|
||||
True
|
||||
if test_function(
|
||||
float(vals[0]), float(conditional_value[0])
|
||||
)
|
||||
else False
|
||||
match = bool(
|
||||
test_function(float(vals[0]), float(conditional_value[0]))
|
||||
)
|
||||
if (match and not negation) or (negation and not match):
|
||||
return ["True"]
|
||||
@@ -603,7 +674,7 @@ class PhotoTemplate:
|
||||
if is_bool:
|
||||
vals = default if not vals else bool_val
|
||||
elif not vals:
|
||||
vals = default or [none_str]
|
||||
vals = default or [self.none_str]
|
||||
|
||||
pre = ts.pre or ""
|
||||
post = ts.post or ""
|
||||
@@ -628,31 +699,30 @@ class PhotoTemplate:
|
||||
self,
|
||||
field,
|
||||
default,
|
||||
bool_val=None,
|
||||
delim=None,
|
||||
path_sep=None,
|
||||
filename=False,
|
||||
dirname=False,
|
||||
subfield=None,
|
||||
# bool_val=None,
|
||||
# delim=None,
|
||||
# path_sep=None,
|
||||
):
|
||||
"""lookup value for template field (single-value template substitutions)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
default: the default value provided by the user
|
||||
bool_val: True value if expression is boolean
|
||||
bool_val: True value if expression is boolean
|
||||
delim: delimiter for expand in place
|
||||
path_sep: path separator for fields that are path-like
|
||||
filename: if True, template output will be sanitized to produce valid file name
|
||||
dirname: if True, template output will be sanitized to produce valid directory name
|
||||
|
||||
subfield: subfield (value after : in field)
|
||||
|
||||
Returns:
|
||||
The matching template value (which may be None).
|
||||
|
||||
Raises:
|
||||
ValueError if no rule exists for field.
|
||||
"""
|
||||
if field not in FIELD_NAMES:
|
||||
raise ValueError(f"SyntaxError: Unknown field: {field}")
|
||||
|
||||
if self.photo.uuid is None:
|
||||
return []
|
||||
|
||||
# initialize today with current date/time if needed
|
||||
if self.today is None:
|
||||
@@ -906,15 +976,73 @@ class PhotoTemplate:
|
||||
value = self.photo.exif_info.lens_model if self.photo.exif_info else None
|
||||
elif field == "uuid":
|
||||
value = self.photo.uuid
|
||||
elif field == "id":
|
||||
value = format_str_value(self.photo._info["pk"], subfield)
|
||||
elif field.startswith("album_seq") or field.startswith("folder_album_seq"):
|
||||
dest_path = self.dest_path
|
||||
if not dest_path:
|
||||
value = None
|
||||
else:
|
||||
if field.startswith("album_seq"):
|
||||
album = pathlib.Path(dest_path).name
|
||||
album_info = _get_album_by_name(self.photo, album)
|
||||
else:
|
||||
album_info = _get_album_by_path(self.photo, dest_path)
|
||||
value = album_info.photo_index(self.photo) if album_info else None
|
||||
if value is not None:
|
||||
try:
|
||||
start_id = field.split(".", 1)
|
||||
value = int(value) + int(start_id[1])
|
||||
except IndexError:
|
||||
pass
|
||||
value = format_str_value(value, subfield)
|
||||
elif field in PUNCTUATION:
|
||||
value = PUNCTUATION[field]
|
||||
elif field == "osxphotos_version":
|
||||
value = __version__
|
||||
elif field == "osxphotos_cmd_line":
|
||||
value = " ".join(sys.argv)
|
||||
else:
|
||||
# if here, didn't get a match
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
if filename:
|
||||
if self.filename:
|
||||
value = sanitize_pathpart(value)
|
||||
elif dirname:
|
||||
elif self.dirname:
|
||||
value = sanitize_dirname(value)
|
||||
|
||||
# ensure no empty strings in value (see #512)
|
||||
value = None if value == "" else value
|
||||
|
||||
return [value]
|
||||
|
||||
def get_template_value_pathlib(self, field):
|
||||
"""lookup value for template pathlib template fields
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
|
||||
Returns:
|
||||
The matching template value (which may be None).
|
||||
|
||||
Raises:
|
||||
ValueError if no rule exists for field.
|
||||
"""
|
||||
field_stem = field.split(".")[0]
|
||||
if field_stem not in PATHLIB_SUBSTITUTIONS:
|
||||
raise ValueError(f"SyntaxError: Unknown field: {field}")
|
||||
|
||||
field_value = None
|
||||
try:
|
||||
field_value = getattr(self, field_stem)
|
||||
except AttributeError:
|
||||
raise ValueError(f"Unknown path-like field: {field_stem}")
|
||||
|
||||
value = _get_pathlib_value(field, field_value, self.quote)
|
||||
|
||||
if self.filename:
|
||||
value = sanitize_pathpart(value)
|
||||
elif self.dirname:
|
||||
value = sanitize_dirname(value)
|
||||
|
||||
return [value]
|
||||
@@ -960,20 +1088,26 @@ class PhotoTemplate:
|
||||
value = ["[" + v + "]" for v in values]
|
||||
else:
|
||||
value = ["[" + values + "]"] if values else []
|
||||
elif filter_ == "shell_quote":
|
||||
if values and type(values) == list:
|
||||
value = [shlex.quote(v) for v in values]
|
||||
else:
|
||||
value = [shlex.quote(values)] if values else []
|
||||
elif filter_.startswith("function:"):
|
||||
value = self.get_template_value_filter_function(filter_, values)
|
||||
else:
|
||||
value = []
|
||||
return value
|
||||
|
||||
def get_template_value_multi(self, field, path_sep, filename=False, dirname=False):
|
||||
def get_template_value_multi(self, field, subfield, path_sep, default):
|
||||
"""lookup value for template field (multi-value template substitutions)
|
||||
|
||||
Args:
|
||||
field: template field to find value for.
|
||||
subfield: the template subfield value
|
||||
path_sep: path separator to use for folder_album field
|
||||
dirname: if True, values will be sanitized to be valid directory names; default = False
|
||||
|
||||
default: value of default field
|
||||
|
||||
Returns:
|
||||
List of the matching template values or [].
|
||||
|
||||
@@ -982,9 +1116,18 @@ class PhotoTemplate:
|
||||
"""
|
||||
|
||||
""" return list of values for a multi-valued template field """
|
||||
|
||||
if self.photo.uuid is None:
|
||||
return []
|
||||
|
||||
values = []
|
||||
if field == "album":
|
||||
values = self.photo.burst_albums if self.photo.burst else self.photo.albums
|
||||
elif field == "project":
|
||||
values = [p.title for p in self.photo.project_info]
|
||||
elif field == "album_project":
|
||||
values = self.photo.burst_albums if self.photo.burst else self.photo.albums
|
||||
values += [p.title for p in self.photo.project_info]
|
||||
elif field == "keyword":
|
||||
values = self.photo.keywords
|
||||
elif field == "person":
|
||||
@@ -995,17 +1138,19 @@ class PhotoTemplate:
|
||||
values = self.photo.labels
|
||||
elif field == "label_normalized":
|
||||
values = self.photo.labels_normalized
|
||||
elif field == "folder_album":
|
||||
elif field in ["folder_album", "folder_album_project"]:
|
||||
values = []
|
||||
# photos must be in an album to be in a folder
|
||||
if self.photo.burst:
|
||||
album_info = self.photo.burst_album_info
|
||||
else:
|
||||
album_info = self.photo.album_info
|
||||
if field == "folder_album_project":
|
||||
album_info += self.photo.project_info
|
||||
for album in album_info:
|
||||
if album.folder_names:
|
||||
# album in folder
|
||||
if dirname:
|
||||
if self.dirname:
|
||||
# being used as a filepath so sanitize each part
|
||||
folder = path_sep.join(
|
||||
sanitize_dirname(f) for f in album.folder_names
|
||||
@@ -1015,12 +1160,10 @@ class PhotoTemplate:
|
||||
folder = path_sep.join(album.folder_names)
|
||||
folder += path_sep + album.title
|
||||
values.append(folder)
|
||||
elif self.dirname:
|
||||
values.append(sanitize_dirname(album.title))
|
||||
else:
|
||||
# album not in folder
|
||||
if dirname:
|
||||
values.append(sanitize_dirname(album.title))
|
||||
else:
|
||||
values.append(album.title)
|
||||
values.append(album.title)
|
||||
elif field == "comment":
|
||||
values = [
|
||||
f"{comment.user}: {comment.text}" for comment in self.photo.comments
|
||||
@@ -1035,6 +1178,10 @@ class PhotoTemplate:
|
||||
values = (
|
||||
self.photo.search_info.venue_types if self.photo.search_info else []
|
||||
)
|
||||
elif field == "shell_quote":
|
||||
values = [shlex.quote(v) for v in default if v]
|
||||
elif field == "strip":
|
||||
values = [v.strip() for v in default]
|
||||
elif field.startswith("photo"):
|
||||
# provide access to PhotoInfo object
|
||||
properties = field.split(".")
|
||||
@@ -1060,14 +1207,16 @@ class PhotoTemplate:
|
||||
elif isinstance(obj, (str, int, float)):
|
||||
values = [str(obj)]
|
||||
else:
|
||||
values = [val for val in obj]
|
||||
values = list(obj)
|
||||
elif field == "detected_text":
|
||||
values = _get_detected_text(self.photo, confidence=subfield)
|
||||
else:
|
||||
raise ValueError(f"Unhandled template value: {field}")
|
||||
|
||||
# sanitize directory names if needed, folder_album handled differently above
|
||||
if filename:
|
||||
if self.filename:
|
||||
values = [sanitize_pathpart(value) for value in values]
|
||||
elif dirname and field != "folder_album":
|
||||
elif self.dirname and field not in ["folder_album", "folder_album_project"]:
|
||||
# skip folder_album because it would have been handled above
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
|
||||
@@ -1075,9 +1224,15 @@ class PhotoTemplate:
|
||||
values = values or []
|
||||
return values
|
||||
|
||||
def get_template_value_exiftool(self, subfield, filename=None, dirname=None):
|
||||
def get_template_value_exiftool(
|
||||
self,
|
||||
subfield,
|
||||
):
|
||||
"""Get template value for format "{exiftool:EXIF:Model}" """
|
||||
|
||||
if self.photo is None:
|
||||
return []
|
||||
|
||||
if not self.photo.path:
|
||||
return []
|
||||
|
||||
@@ -1090,17 +1245,20 @@ class PhotoTemplate:
|
||||
values = [str(v) for v in values]
|
||||
|
||||
# sanitize directory names if needed
|
||||
if filename:
|
||||
if self.filename:
|
||||
values = [sanitize_pathpart(value) for value in values]
|
||||
elif dirname:
|
||||
elif self.dirname:
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
else:
|
||||
values = []
|
||||
|
||||
return values
|
||||
|
||||
def get_template_value_function(self, subfield, filename=None, dirname=None):
|
||||
"""Get template value from external function """
|
||||
def get_template_value_function(
|
||||
self,
|
||||
subfield,
|
||||
):
|
||||
"""Get template value from external function"""
|
||||
|
||||
if "::" not in subfield:
|
||||
raise ValueError(
|
||||
@@ -1109,11 +1267,12 @@ class PhotoTemplate:
|
||||
|
||||
filename, funcname = subfield.split("::")
|
||||
|
||||
if not pathlib.Path(filename).is_file():
|
||||
filename_validated = expand_and_validate_filepath(filename)
|
||||
if not filename_validated:
|
||||
raise ValueError(f"'{filename}' does not appear to be a file")
|
||||
|
||||
template_func = load_function(filename, funcname)
|
||||
values = template_func(self.photo)
|
||||
template_func = load_function(filename_validated, funcname)
|
||||
values = template_func(self.photo, options=self.options)
|
||||
|
||||
if not isinstance(values, (str, list)):
|
||||
raise TypeError(
|
||||
@@ -1123,17 +1282,18 @@ class PhotoTemplate:
|
||||
values = [values]
|
||||
|
||||
# sanitize directory names if needed
|
||||
if filename:
|
||||
if self.filename:
|
||||
values = [sanitize_pathpart(value) for value in values]
|
||||
elif dirname:
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
elif self.dirname:
|
||||
# sanitize but don't replace any "/" as user function may want to create sub directories
|
||||
values = [sanitize_dirname(value, replacement=None) for value in values]
|
||||
|
||||
return values
|
||||
|
||||
def get_template_value_filter_function(self, filter_, values):
|
||||
"""Filter template value from external function """
|
||||
"""Filter template value from external function"""
|
||||
|
||||
filter_ = filter_.replace("function:","")
|
||||
filter_ = filter_.replace("function:", "")
|
||||
|
||||
if "::" not in filter_:
|
||||
raise ValueError(
|
||||
@@ -1142,10 +1302,11 @@ class PhotoTemplate:
|
||||
|
||||
filename, funcname = filter_.split("::")
|
||||
|
||||
if not pathlib.Path(filename).is_file():
|
||||
filename_validated = expand_and_validate_filepath(filename)
|
||||
if not filename_validated:
|
||||
raise ValueError(f"'{filename}' does not appear to be a file")
|
||||
|
||||
template_func = load_function(filename, funcname)
|
||||
template_func = load_function(filename_validated, funcname)
|
||||
|
||||
if not isinstance(values, (list, tuple)):
|
||||
values = [values]
|
||||
@@ -1158,9 +1319,8 @@ class PhotoTemplate:
|
||||
|
||||
return values
|
||||
|
||||
|
||||
def get_photo_video_type(self, default):
|
||||
""" return media type, e.g. photo or video """
|
||||
"""return media type, e.g. photo or video"""
|
||||
default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS)
|
||||
if self.photo.isphoto:
|
||||
return default_dict["photo"]
|
||||
@@ -1168,7 +1328,7 @@ class PhotoTemplate:
|
||||
return default_dict["video"]
|
||||
|
||||
def get_media_type(self, default):
|
||||
""" return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type """
|
||||
"""return special media type, e.g. slow_mo, panorama, etc., defaults to photo or video if no special type"""
|
||||
default_dict = parse_default_kv(default, MEDIA_TYPE_DEFAULTS)
|
||||
p = self.photo
|
||||
if p.selfie:
|
||||
@@ -1202,7 +1362,7 @@ class PhotoTemplate:
|
||||
|
||||
|
||||
def parse_default_kv(default, default_dict):
|
||||
""" parse a string in form key1=value1;key2=value2,... as used for some template fields
|
||||
"""parse a string in form key1=value1;key2=value2,... as used for some template fields
|
||||
|
||||
Args:
|
||||
default: str, in form 'photo=foto;video=vidéo'
|
||||
@@ -1227,9 +1387,85 @@ def parse_default_kv(default, default_dict):
|
||||
|
||||
|
||||
def get_template_help():
|
||||
"""Return help for template system as markdown string """
|
||||
"""Return help for template system as markdown string"""
|
||||
# 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 / "phototemplate.md"
|
||||
with open(help_file, "r") as fd:
|
||||
md = fd.read()
|
||||
return md
|
||||
|
||||
|
||||
def _get_pathlib_value(field, value, quote):
|
||||
"""Get the value for a pathlib.Path type template
|
||||
|
||||
Args:
|
||||
field: the path field, e.g. "filename.stem"
|
||||
value: the value for the path component
|
||||
quote: bool; if true, quotes the returned path for safe execution in the shell
|
||||
"""
|
||||
parts = field.split(".")
|
||||
|
||||
if len(parts) == 1:
|
||||
return shlex.quote(value) if quote else value
|
||||
|
||||
if len(parts) > 2:
|
||||
raise ValueError(f"Illegal value for path template: {field}")
|
||||
|
||||
path = parts[0]
|
||||
attribute = parts[1]
|
||||
path = pathlib.Path(value)
|
||||
try:
|
||||
val = getattr(path, attribute)
|
||||
val_str = str(val)
|
||||
if quote:
|
||||
val_str = shlex.quote(val_str)
|
||||
return val_str
|
||||
except AttributeError:
|
||||
raise ValueError("Illegal value for path template: {attribute}")
|
||||
|
||||
|
||||
def format_str_value(value, format_str):
|
||||
"""Format value based on format code in field in format id:02d"""
|
||||
if not format_str:
|
||||
return str(value)
|
||||
format_str = "{0:" + f"{format_str}" + "}"
|
||||
return format_str.format(value)
|
||||
|
||||
|
||||
def _get_album_by_name(photo, album):
|
||||
"""Finds first album named album that photo is in and returns the AlbumInfo object, otherwise returns None"""
|
||||
for album_info in photo.album_info:
|
||||
if album_info.title == album:
|
||||
return album_info
|
||||
return None
|
||||
|
||||
|
||||
def _get_album_by_path(photo, folder_album_path):
|
||||
"""finds the first album whose folder_album path matches and folder_album_path and returns the AlbumInfo object, otherwise, returns None"""
|
||||
|
||||
for album_info in photo.album_info:
|
||||
# following code is how {folder_album} builds the folder path
|
||||
folder = "/".join(sanitize_dirname(f) for f in album_info.folder_names)
|
||||
folder += "/" + sanitize_dirname(album_info.title)
|
||||
if folder_album_path.endswith(folder):
|
||||
return album_info
|
||||
return None
|
||||
|
||||
|
||||
def _get_detected_text(photo, confidence=TEXT_DETECTION_CONFIDENCE_THRESHOLD):
|
||||
"""Returns the detected text for a photo
|
||||
{detected_text} uses this instead of PhotoInfo.detected_text() to cache the text for all confidence values
|
||||
"""
|
||||
if not photo.isphoto:
|
||||
return []
|
||||
|
||||
confidence = (
|
||||
float(confidence)
|
||||
if confidence is not None
|
||||
else TEXT_DETECTION_CONFIDENCE_THRESHOLD
|
||||
)
|
||||
|
||||
# _detected_text caches the text detection results in an extended attribute
|
||||
# so the first time this gets called is slow but repeated accesses are fast
|
||||
detected_text = photo._detected_text()
|
||||
return [text for text, conf in detected_text if conf >= confidence]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// OSXPhotos Template Language (OTL)
|
||||
// OSXPhotos Metadata Template Language (MTL)
|
||||
// a TemplateString has format:
|
||||
// pre{delim+template_field:subfield|filter(path_sep)[find,replace] conditional?bool_value,default}post
|
||||
// a TemplateStatement may contain zero or more TemplateStrings
|
||||
@@ -63,7 +63,8 @@ SubField:
|
||||
;
|
||||
|
||||
SUBFIELD_WORD:
|
||||
/[\.\w:\/]+/
|
||||
/[\.\w:\/\-\~\'\"\%\@\#\^\’]+/
|
||||
/\\\s/?
|
||||
;
|
||||
|
||||
Filter:
|
||||
|
||||
@@ -14,6 +14,16 @@ from bpylist import archiver
|
||||
from ._constants import UNICODE_FORMAT
|
||||
from .utils import normalize_unicode
|
||||
|
||||
__all__ = [
|
||||
"PLRevGeoLocationInfo",
|
||||
"PLRevGeoMapItem",
|
||||
"PLRevGeoMapItemAdditionalPlaceInfo",
|
||||
"CNPostalAddress",
|
||||
"PlaceInfo",
|
||||
"PlaceInfo4",
|
||||
"PlaceInfo5",
|
||||
]
|
||||
|
||||
# postal address information, returned by PlaceInfo.address
|
||||
PostalAddress = namedtuple(
|
||||
"PostalAddress",
|
||||
@@ -65,7 +75,7 @@ PlaceNames = namedtuple(
|
||||
# in ZADDITIONALASSETATTRIBUTES.ZREVERSELOCATIONDATA
|
||||
# These classes are used by bpylist.archiver to unarchive the serialized objects
|
||||
class PLRevGeoLocationInfo:
|
||||
""" The top level reverse geolocation object """
|
||||
"""The top level reverse geolocation object"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -147,7 +157,7 @@ class PLRevGeoLocationInfo:
|
||||
|
||||
|
||||
class PLRevGeoMapItem:
|
||||
""" Stores the list of place names, organized by area """
|
||||
"""Stores the list of place names, organized by area"""
|
||||
|
||||
def __init__(self, sortedPlaceInfos, finalPlaceInfos):
|
||||
self.sortedPlaceInfos = sortedPlaceInfos
|
||||
@@ -182,7 +192,7 @@ class PLRevGeoMapItem:
|
||||
|
||||
|
||||
class PLRevGeoMapItemAdditionalPlaceInfo:
|
||||
""" Additional info about individual places """
|
||||
"""Additional info about individual places"""
|
||||
|
||||
def __init__(self, area, name, placeType, dominantOrderType):
|
||||
self.area = area
|
||||
@@ -221,7 +231,7 @@ class PLRevGeoMapItemAdditionalPlaceInfo:
|
||||
|
||||
|
||||
class CNPostalAddress:
|
||||
""" postal address for the reverse geolocation info """
|
||||
"""postal address for the reverse geolocation info"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -354,17 +364,17 @@ class PlaceInfo(ABC):
|
||||
|
||||
|
||||
class PlaceInfo4(PlaceInfo):
|
||||
""" Reverse geolocation place info for a photo (Photos <= 4) """
|
||||
"""Reverse geolocation place info for a photo (Photos <= 4)"""
|
||||
|
||||
def __init__(self, place_names, country_code):
|
||||
""" place_names: list of place name tuples in ascending order by area
|
||||
tuple fields are: modelID, place name, place type, area, e.g.
|
||||
[(5, "St James's Park", 45, 0),
|
||||
(4, 'Westminster', 16, 22097376),
|
||||
(3, 'London', 4, 1596146816),
|
||||
(2, 'England', 2, 180406091776),
|
||||
(1, 'United Kingdom', 1, 414681432064)]
|
||||
country_code: two letter country code for the country
|
||||
"""place_names: list of place name tuples in ascending order by area
|
||||
tuple fields are: modelID, place name, place type, area, e.g.
|
||||
[(5, "St James's Park", 45, 0),
|
||||
(4, 'Westminster', 16, 22097376),
|
||||
(3, 'London', 4, 1596146816),
|
||||
(2, 'England', 2, 180406091776),
|
||||
(1, 'United Kingdom', 1, 414681432064)]
|
||||
country_code: two letter country code for the country
|
||||
"""
|
||||
self._place_names = place_names
|
||||
self._country_code = country_code
|
||||
@@ -404,7 +414,7 @@ class PlaceInfo4(PlaceInfo):
|
||||
)
|
||||
|
||||
def _process_place_info(self):
|
||||
""" Process place_names to set self._name and self._names """
|
||||
"""Process place_names to set self._name and self._names"""
|
||||
places = self._place_names
|
||||
|
||||
# build a dictionary where key is placetype
|
||||
@@ -500,38 +510,38 @@ class PlaceInfo4(PlaceInfo):
|
||||
|
||||
|
||||
class PlaceInfo5(PlaceInfo):
|
||||
""" Reverse geolocation place info for a photo (Photos >= 5) """
|
||||
"""Reverse geolocation place info for a photo (Photos >= 5)"""
|
||||
|
||||
def __init__(self, revgeoloc_bplist):
|
||||
""" revgeoloc_bplist: a binary plist blob containing
|
||||
a serialized PLRevGeoLocationInfo object """
|
||||
"""revgeoloc_bplist: a binary plist blob containing
|
||||
a serialized PLRevGeoLocationInfo object"""
|
||||
self._bplist = revgeoloc_bplist
|
||||
self._plrevgeoloc = archiver.unarchive(revgeoloc_bplist)
|
||||
self._process_place_info()
|
||||
|
||||
@property
|
||||
def address_str(self):
|
||||
""" returns the postal address as a string """
|
||||
"""returns the postal address as a string"""
|
||||
return self._plrevgeoloc.addressString
|
||||
|
||||
@property
|
||||
def country_code(self):
|
||||
""" returns the country code """
|
||||
"""returns the country code"""
|
||||
return self._plrevgeoloc.countryCode
|
||||
|
||||
@property
|
||||
def ishome(self):
|
||||
""" returns True if place is user's home address """
|
||||
"""returns True if place is user's home address"""
|
||||
return self._plrevgeoloc.isHome
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" returns local place name """
|
||||
"""returns local place name"""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
""" returns PlaceNames tuple with detailed reverse geolocation place names """
|
||||
"""returns PlaceNames tuple with detailed reverse geolocation place names"""
|
||||
return self._names
|
||||
|
||||
@property
|
||||
@@ -556,7 +566,7 @@ class PlaceInfo5(PlaceInfo):
|
||||
return postal_address
|
||||
|
||||
def _process_place_info(self):
|
||||
""" Process sortedPlaceInfos to set self._name and self._names """
|
||||
"""Process sortedPlaceInfos to set self._name and self._names"""
|
||||
places = self._plrevgeoloc.mapItem.sortedPlaceInfos
|
||||
|
||||
# build a dictionary where key is placetype
|
||||
|
||||
132
osxphotos/pyrepl.py
Normal file
132
osxphotos/pyrepl.py
Normal file
@@ -0,0 +1,132 @@
|
||||
__all__ = ["PyReplQuitter", "embed_repl"]
|
||||
""" Custom Python REPL based on ptpython that allows quitting with custom keywords instead of `quit()` """
|
||||
|
||||
""" This file is distributed under the same license as the ptpython package:
|
||||
|
||||
Copyright (c) 2015, Jonathan Slenders (ptpython), (c) 2021 Rhet Turnbull (this file)
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* 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.
|
||||
|
||||
* Neither the name of the {organization} 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.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from ptpython.repl import (
|
||||
ContextManager,
|
||||
DummyContext,
|
||||
PythonRepl,
|
||||
builtins,
|
||||
patch_stdout_context,
|
||||
)
|
||||
|
||||
|
||||
class PyReplQuitter(PythonRepl):
|
||||
"""Custom pypython repl that allows quitting REPL with custom commands"""
|
||||
|
||||
def __init__(self, *args, quit_words: Optional[List[str]] = None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.quit_words = quit_words or ["quit", "q"]
|
||||
|
||||
def eval(self, line: str) -> object:
|
||||
if line.strip() in self.quit_words:
|
||||
sys.exit(0)
|
||||
return super().eval(line)
|
||||
|
||||
|
||||
def embed_repl(
|
||||
globals=None,
|
||||
locals=None,
|
||||
configure: Optional[Callable[[PythonRepl], None]] = None,
|
||||
vi_mode: bool = False,
|
||||
history_filename: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
startup_paths=None,
|
||||
patch_stdout: bool = False,
|
||||
return_asyncio_coroutine: bool = False,
|
||||
quit_words: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Call this to embed Python shell at the current point in your program.
|
||||
It's similar to `IPython.embed` and `bpython.embed`. ::
|
||||
from prompt_toolkit.contrib.repl import embed
|
||||
embed(globals(), locals())
|
||||
:param vi_mode: Boolean. Use Vi instead of Emacs key bindings.
|
||||
:param configure: Callable that will be called with the `PythonRepl` as a first
|
||||
argument, to trigger configuration.
|
||||
:param title: Title to be displayed in the terminal titlebar. (None or string.)
|
||||
:param patch_stdout: When true, patch `sys.stdout` so that background
|
||||
threads that are printing will print nicely above the prompt.
|
||||
"""
|
||||
# Default globals/locals
|
||||
if globals is None:
|
||||
globals = {
|
||||
"__name__": "__main__",
|
||||
"__package__": None,
|
||||
"__doc__": None,
|
||||
"__builtins__": builtins,
|
||||
}
|
||||
|
||||
locals = locals or globals
|
||||
|
||||
def get_globals():
|
||||
return globals
|
||||
|
||||
def get_locals():
|
||||
return locals
|
||||
|
||||
# Create REPL.
|
||||
repl = PyReplQuitter(
|
||||
get_globals=get_globals,
|
||||
get_locals=get_locals,
|
||||
vi_mode=vi_mode,
|
||||
history_filename=history_filename,
|
||||
startup_paths=startup_paths,
|
||||
quit_words=quit_words,
|
||||
)
|
||||
|
||||
if title:
|
||||
repl.terminal_title = title
|
||||
|
||||
if configure:
|
||||
configure(repl)
|
||||
|
||||
# Start repl.
|
||||
patch_context: ContextManager = (
|
||||
patch_stdout_context() if patch_stdout else DummyContext()
|
||||
)
|
||||
|
||||
if return_asyncio_coroutine:
|
||||
|
||||
async def coroutine():
|
||||
with patch_context:
|
||||
await repl.run_async()
|
||||
|
||||
return coroutine()
|
||||
else:
|
||||
with patch_context:
|
||||
repl.run()
|
||||
5
osxphotos/queries/README.md
Normal file
5
osxphotos/queries/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Query templates
|
||||
|
||||
This folder contains sql query templates for getting various photo properties
|
||||
|
||||
The query templates must be rendered with mako (see query_builder.py)
|
||||
4
osxphotos/queries/cloud_album_owner.sql.mako
Normal file
4
osxphotos/queries/cloud_album_owner.sql.mako
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Get owner name for shared iCloud album
|
||||
SELECT ZGENERICALBUM.ZCLOUDOWNERFULLNAME AS OWNER_FULLNAME
|
||||
FROM ZGENERICALBUM
|
||||
WHERE ZGENERICALBUM.ZUUID = '${uuid}'
|
||||
23
osxphotos/queries/shared_owner.sql.mako
Normal file
23
osxphotos/queries/shared_owner.sql.mako
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Get the owner name of person who owns a photo in a shared album
|
||||
--
|
||||
-- Case where someone has invited you to a shared album
|
||||
-- Need to get the owner of the shared album
|
||||
SELECT DISTINCT
|
||||
ZGENERICALBUM.ZCLOUDOWNERFULLNAME as OWNER_FULLNAME
|
||||
FROM ZGENERICALBUM
|
||||
JOIN ${asset_table} ON ${asset_table}.ZCLOUDOWNERHASHEDPERSONID = ZGENERICALBUM.ZCLOUDOWNERHASHEDPERSONID
|
||||
WHERE ${asset_table}.ZUUID = "${uuid}"
|
||||
AND ZGENERICALBUM.ZCLOUDOWNERHASHEDPERSONID IS NOT NULL
|
||||
AND ZGENERICALBUM.ZCLOUDOWNERHASHEDPERSONID != ""
|
||||
AND OWNER_FULLNAME != "(null) (null)"
|
||||
UNION
|
||||
-- Case where you have invited someone to a shared album
|
||||
-- Need to get the data for person who was invited to the album
|
||||
SELECT DISTINCT
|
||||
ZCLOUDSHAREDALBUMINVITATIONRECORD.ZINVITEEFULLNAME AS OWNER_FULLNAME
|
||||
FROM ZCLOUDSHAREDALBUMINVITATIONRECORD
|
||||
JOIN ${asset_table} ON ${asset_table}.ZCLOUDOWNERHASHEDPERSONID = ZCLOUDSHAREDALBUMINVITATIONRECORD.ZINVITEEHASHEDPERSONID
|
||||
WHERE ${asset_table}.ZUUID = "${uuid}"
|
||||
AND ZCLOUDSHAREDALBUMINVITATIONRECORD.ZINVITEEHASHEDPERSONID IS NOT NULL
|
||||
AND ZCLOUDSHAREDALBUMINVITATIONRECORD.ZINVITEEHASHEDPERSONID != ""
|
||||
AND OWNER_FULLNAME != "(null) (null)"
|
||||
6
osxphotos/queries/title.sql.mako
Normal file
6
osxphotos/queries/title.sql.mako
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Get title of a photo with given UUID
|
||||
SELECT
|
||||
ZADDITIONALASSETATTRIBUTES.ZTITLE
|
||||
FROM ZADDITIONALASSETATTRIBUTES
|
||||
JOIN ${asset_table} ON ${asset_table}.Z_PK = ZADDITIONALASSETATTRIBUTES.ZASSET
|
||||
WHERE ${asset_table}.ZUUID = "${uuid}"
|
||||
38
osxphotos/query_builder.py
Normal file
38
osxphotos/query_builder.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Build sql queries from template to retrieve info from the database"""
|
||||
|
||||
import os.path
|
||||
import pathlib
|
||||
from functools import lru_cache
|
||||
|
||||
from mako.template import Template
|
||||
|
||||
from ._constants import _DB_TABLE_NAMES
|
||||
|
||||
__all__ = ["get_query"]
|
||||
|
||||
QUERY_DIR = os.path.join(os.path.dirname(__file__), "queries")
|
||||
|
||||
|
||||
def get_query(query_name, photos_ver, **kwargs):
|
||||
"""Return sqlite query string for an attribute and a given database version"""
|
||||
|
||||
# there can be a single query for multiple database versions or separate queries for each version
|
||||
# try generic version first (most common case), if that fails, look for version specific query
|
||||
query_string = _get_query_string(query_name, photos_ver)
|
||||
asset_table = _DB_TABLE_NAMES[photos_ver]["ASSET"]
|
||||
query_template = Template(query_string)
|
||||
return query_template.render(asset_table=asset_table, **kwargs)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _get_query_string(query_name, photos_ver):
|
||||
"""Return sqlite query string for an attribute and a given database version"""
|
||||
query_file = pathlib.Path(QUERY_DIR) / f"{query_name}.sql.mako"
|
||||
if not query_file.is_file():
|
||||
query_file = pathlib.Path(QUERY_DIR) / f"{query_name}_{photos_ver}.sql.mako"
|
||||
if not query_file.is_file():
|
||||
raise FileNotFoundError(f"Query file '{query_file}' not found")
|
||||
|
||||
with open(query_file, "r") as f:
|
||||
query_string = f.read()
|
||||
return query_string
|
||||
@@ -1,10 +1,13 @@
|
||||
""" QueryOptions class for PhotosDB.query """
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Iterable, Tuple
|
||||
import datetime
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
|
||||
import bitmath
|
||||
|
||||
__all__ = ["QueryOptions"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryOptions:
|
||||
@@ -30,7 +33,7 @@ class QueryOptions:
|
||||
shared: Optional[bool] = None
|
||||
not_shared: Optional[bool] = None
|
||||
photos: Optional[bool] = True
|
||||
movies: Optional[bool] = True
|
||||
movies: Optional[bool] = True
|
||||
uti: Optional[Iterable[str]] = None
|
||||
burst: Optional[bool] = None
|
||||
not_burst: Optional[bool] = None
|
||||
@@ -78,6 +81,12 @@ class QueryOptions:
|
||||
max_size: Optional[bitmath.Byte] = None
|
||||
regex: Optional[Iterable[Tuple[str, str]]] = None
|
||||
query_eval: Optional[Iterable[str]] = None
|
||||
duplicate: Optional[bool] = None
|
||||
location: Optional[bool] = None
|
||||
no_location: Optional[bool] = None
|
||||
function: Optional[List[Tuple[callable, str]]] = None
|
||||
selected: Optional[bool] = None
|
||||
exif: Optional[Iterable[Tuple[str, str]]] = None
|
||||
|
||||
def asdict(self):
|
||||
return asdict(self)
|
||||
|
||||
40
osxphotos/scoreinfo.py
Normal file
40
osxphotos/scoreinfo.py
Normal file
@@ -0,0 +1,40 @@
|
||||
""" ScoreInfo class to expose computed score info from the library """
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ._constants import _PHOTOS_4_VERSION
|
||||
|
||||
__all__ = ["ScoreInfo"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScoreInfo:
|
||||
"""Computed photo score info associated with a photo from the Photos library"""
|
||||
|
||||
overall: float
|
||||
curation: float
|
||||
promotion: float
|
||||
highlight_visibility: float
|
||||
behavioral: float
|
||||
failure: float
|
||||
harmonious_color: float
|
||||
immersiveness: float
|
||||
interaction: float
|
||||
interesting_subject: float
|
||||
intrusive_object_presence: float
|
||||
lively_color: float
|
||||
low_light: float
|
||||
noise: float
|
||||
pleasant_camera_tilt: float
|
||||
pleasant_composition: float
|
||||
pleasant_lighting: float
|
||||
pleasant_pattern: float
|
||||
pleasant_perspective: float
|
||||
pleasant_post_processing: float
|
||||
pleasant_reflection: float
|
||||
pleasant_symmetry: float
|
||||
sharply_focused_subject: float
|
||||
tastefully_blurred: float
|
||||
well_chosen_subject: float
|
||||
well_framed_subject: float
|
||||
well_timed_shot: float
|
||||
@@ -1,98 +1,41 @@
|
||||
""" Methods and class for PhotoInfo exposing SearchInfo data such as labels
|
||||
Adds the following properties to PhotoInfo (valid only for Photos 5):
|
||||
search_info: returns a SearchInfo object
|
||||
search_info_normalized: returns a SearchInfo object with properties that produce normalized results
|
||||
labels: returns list of labels
|
||||
labels_normalized: returns list of normalized labels
|
||||
""" class for PhotoInfo exposing SearchInfo data such as labels
|
||||
"""
|
||||
|
||||
from .._constants import (
|
||||
from ._constants import (
|
||||
_PHOTOS_4_VERSION,
|
||||
SEARCH_CATEGORY_ACTIVITY,
|
||||
SEARCH_CATEGORY_ALL_LOCALITY,
|
||||
SEARCH_CATEGORY_BODY_OF_WATER,
|
||||
SEARCH_CATEGORY_CITY,
|
||||
SEARCH_CATEGORY_COUNTRY,
|
||||
SEARCH_CATEGORY_HOLIDAY,
|
||||
SEARCH_CATEGORY_LABEL,
|
||||
SEARCH_CATEGORY_MEDIA_TYPES,
|
||||
SEARCH_CATEGORY_MONTH,
|
||||
SEARCH_CATEGORY_NEIGHBORHOOD,
|
||||
SEARCH_CATEGORY_PLACE_NAME,
|
||||
SEARCH_CATEGORY_STREET,
|
||||
SEARCH_CATEGORY_ALL_LOCALITY,
|
||||
SEARCH_CATEGORY_COUNTRY,
|
||||
SEARCH_CATEGORY_SEASON,
|
||||
SEARCH_CATEGORY_STATE,
|
||||
SEARCH_CATEGORY_STATE_ABBREVIATION,
|
||||
SEARCH_CATEGORY_BODY_OF_WATER,
|
||||
SEARCH_CATEGORY_MONTH,
|
||||
SEARCH_CATEGORY_YEAR,
|
||||
SEARCH_CATEGORY_HOLIDAY,
|
||||
SEARCH_CATEGORY_ACTIVITY,
|
||||
SEARCH_CATEGORY_SEASON,
|
||||
SEARCH_CATEGORY_STREET,
|
||||
SEARCH_CATEGORY_VENUE,
|
||||
SEARCH_CATEGORY_VENUE_TYPE,
|
||||
SEARCH_CATEGORY_MEDIA_TYPES,
|
||||
SEARCH_CATEGORY_YEAR,
|
||||
)
|
||||
|
||||
|
||||
@property
|
||||
def search_info(self):
|
||||
""" returns SearchInfo object for photo
|
||||
only valid on Photos 5, on older libraries, returns None
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
# memoize SearchInfo object
|
||||
try:
|
||||
return self._search_info
|
||||
except AttributeError:
|
||||
self._search_info = SearchInfo(self)
|
||||
return self._search_info
|
||||
|
||||
|
||||
@property
|
||||
def search_info_normalized(self):
|
||||
""" returns SearchInfo object for photo that produces normalized results
|
||||
only valid on Photos 5, on older libraries, returns None
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return None
|
||||
|
||||
# memoize SearchInfo object
|
||||
try:
|
||||
return self._search_info_normalized
|
||||
except AttributeError:
|
||||
self._search_info_normalized = SearchInfo(self, normalized=True)
|
||||
return self._search_info_normalized
|
||||
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" returns list of labels applied to photo by Photos image categorization
|
||||
only valid on Photos 5, on older libraries returns empty list
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info.labels
|
||||
|
||||
|
||||
@property
|
||||
def labels_normalized(self):
|
||||
""" returns normalized list of labels applied to photo by Photos image categorization
|
||||
only valid on Photos 5, on older libraries returns empty list
|
||||
"""
|
||||
if self._db._db_version <= _PHOTOS_4_VERSION:
|
||||
return []
|
||||
|
||||
return self.search_info_normalized.labels
|
||||
__all__ = ["SearchInfo"]
|
||||
|
||||
|
||||
class SearchInfo:
|
||||
""" Info about search terms such as machine learning labels that Photos knows about a photo """
|
||||
"""Info about search terms such as machine learning labels that Photos knows about a photo"""
|
||||
|
||||
def __init__(self, photo, normalized=False):
|
||||
""" photo: PhotoInfo object
|
||||
normalized: if True, all properties return normalized (lower case) results """
|
||||
"""photo: PhotoInfo object
|
||||
normalized: if True, all properties return normalized (lower case) results"""
|
||||
|
||||
if photo._db._db_version <= _PHOTOS_4_VERSION:
|
||||
raise NotImplementedError(
|
||||
f"search info not implemented for this database version"
|
||||
"search info not implemented for this database version"
|
||||
)
|
||||
|
||||
self._photo = photo
|
||||
@@ -107,27 +50,27 @@ class SearchInfo:
|
||||
|
||||
@property
|
||||
def labels(self):
|
||||
""" return list of labels associated with Photo """
|
||||
"""return list of labels associated with Photo"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_LABEL)
|
||||
|
||||
@property
|
||||
def place_names(self):
|
||||
""" returns list of place names """
|
||||
"""returns list of place names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_PLACE_NAME)
|
||||
|
||||
@property
|
||||
def streets(self):
|
||||
""" returns list of street names """
|
||||
"""returns list of street names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_STREET)
|
||||
|
||||
@property
|
||||
def neighborhoods(self):
|
||||
""" returns list of neighborhoods """
|
||||
"""returns list of neighborhoods"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_NEIGHBORHOOD)
|
||||
|
||||
@property
|
||||
def locality_names(self):
|
||||
""" returns list of other locality names """
|
||||
"""returns list of other locality names"""
|
||||
locality = []
|
||||
for category in SEARCH_CATEGORY_ALL_LOCALITY:
|
||||
locality += self._get_text_for_category(category)
|
||||
@@ -135,74 +78,74 @@ class SearchInfo:
|
||||
|
||||
@property
|
||||
def city(self):
|
||||
""" returns city/town """
|
||||
"""returns city/town"""
|
||||
city = self._get_text_for_category(SEARCH_CATEGORY_CITY)
|
||||
return city[0] if city else ""
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" returns state name """
|
||||
"""returns state name"""
|
||||
state = self._get_text_for_category(SEARCH_CATEGORY_STATE)
|
||||
return state[0] if state else ""
|
||||
|
||||
@property
|
||||
def state_abbreviation(self):
|
||||
""" returns state abbreviation """
|
||||
"""returns state abbreviation"""
|
||||
abbrev = self._get_text_for_category(SEARCH_CATEGORY_STATE_ABBREVIATION)
|
||||
return abbrev[0] if abbrev else ""
|
||||
|
||||
@property
|
||||
def country(self):
|
||||
""" returns country name """
|
||||
"""returns country name"""
|
||||
country = self._get_text_for_category(SEARCH_CATEGORY_COUNTRY)
|
||||
return country[0] if country else ""
|
||||
|
||||
@property
|
||||
def month(self):
|
||||
""" returns month name """
|
||||
"""returns month name"""
|
||||
month = self._get_text_for_category(SEARCH_CATEGORY_MONTH)
|
||||
return month[0] if month else ""
|
||||
|
||||
@property
|
||||
def year(self):
|
||||
""" returns year """
|
||||
"""returns year"""
|
||||
year = self._get_text_for_category(SEARCH_CATEGORY_YEAR)
|
||||
return year[0] if year else ""
|
||||
|
||||
@property
|
||||
def bodies_of_water(self):
|
||||
""" returns list of body of water names """
|
||||
"""returns list of body of water names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_BODY_OF_WATER)
|
||||
|
||||
@property
|
||||
def holidays(self):
|
||||
""" returns list of holiday names """
|
||||
"""returns list of holiday names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_HOLIDAY)
|
||||
|
||||
@property
|
||||
def activities(self):
|
||||
""" returns list of activity names """
|
||||
"""returns list of activity names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_ACTIVITY)
|
||||
|
||||
@property
|
||||
def season(self):
|
||||
""" returns season name """
|
||||
"""returns season name"""
|
||||
season = self._get_text_for_category(SEARCH_CATEGORY_SEASON)
|
||||
return season[0] if season else ""
|
||||
|
||||
@property
|
||||
def venues(self):
|
||||
""" returns list of venue names """
|
||||
"""returns list of venue names"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_VENUE)
|
||||
|
||||
@property
|
||||
def venue_types(self):
|
||||
""" returns list of venue types """
|
||||
"""returns list of venue types"""
|
||||
return self._get_text_for_category(SEARCH_CATEGORY_VENUE_TYPE)
|
||||
|
||||
@property
|
||||
def media_types(self):
|
||||
""" returns list of media types (photo, video, panorama, etc) """
|
||||
"""returns list of media types (photo, video, panorama, etc)"""
|
||||
types = []
|
||||
for category in SEARCH_CATEGORY_MEDIA_TYPES:
|
||||
types += self._get_text_for_category(category)
|
||||
@@ -210,7 +153,7 @@ class SearchInfo:
|
||||
|
||||
@property
|
||||
def all(self):
|
||||
""" return all search info properties in a single list """
|
||||
"""return all search info properties in a single list"""
|
||||
all = (
|
||||
self.labels
|
||||
+ self.place_names
|
||||
@@ -242,7 +185,7 @@ class SearchInfo:
|
||||
return all
|
||||
|
||||
def asdict(self):
|
||||
""" return dict of search info """
|
||||
"""return dict of search info"""
|
||||
return {
|
||||
"labels": self.labels,
|
||||
"place_names": self.place_names,
|
||||
@@ -265,7 +208,7 @@ class SearchInfo:
|
||||
}
|
||||
|
||||
def _get_text_for_category(self, category):
|
||||
""" return list of text for a specified category ID """
|
||||
"""return list of text for a specified category ID"""
|
||||
if self._db_searchinfo:
|
||||
content = "normalized_string" if self._normalized else "content_string"
|
||||
return [
|
||||
57
osxphotos/sqlgrep.py
Normal file
57
osxphotos/sqlgrep.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Search through a sqlite database file for a given string"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Generator, List
|
||||
|
||||
__all__ = ["sqlgrep"]
|
||||
|
||||
|
||||
def sqlgrep(
|
||||
filename: str,
|
||||
pattern: str,
|
||||
ignore_case: bool = False,
|
||||
print_filename: bool = True,
|
||||
rich_markup: bool = False,
|
||||
) -> Generator[List[str], None, None]:
|
||||
"""grep through a sqlite database file for a given string
|
||||
|
||||
Args:
|
||||
filename (str): The filename of the sqlite database file
|
||||
pattern (str): The pattern to search for
|
||||
ignore_case (bool, optional): Ignore case when searching. Defaults to False.
|
||||
print_filename (bool, optional): include the filename of the file with table name. Defaults to True.
|
||||
rich_markup (bool, optional): Add rich markup to mark found text in bold. Defaults to False.
|
||||
|
||||
Returns:
|
||||
Generator which yields list of [table, column, row_id, value]
|
||||
"""
|
||||
flags = re.IGNORECASE if ignore_case else 0
|
||||
try:
|
||||
with sqlite3.connect(f"file:{filename}?mode=ro", uri=True) as conn:
|
||||
regex = re.compile(r"(" + pattern + r")", flags=flags)
|
||||
filename_header = f"{filename}: " if print_filename else ""
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
for tablerow in cursor.fetchall():
|
||||
table = tablerow[0]
|
||||
cursor.execute("SELECT * FROM {t}".format(t=table))
|
||||
for row_num, row in enumerate(cursor):
|
||||
for field in row.keys():
|
||||
field_value = row[field]
|
||||
if not field_value or type(field_value) == bytes:
|
||||
# don't search binary blobs
|
||||
next
|
||||
field_value = str(field_value)
|
||||
if re.search(pattern, field_value, flags=flags):
|
||||
if rich_markup:
|
||||
field_value = regex.sub(r"[bold]\1[/bold]", field_value)
|
||||
yield [
|
||||
f"{filename_header}{table}",
|
||||
field,
|
||||
str(row_num),
|
||||
field_value,
|
||||
]
|
||||
except sqlite3.DatabaseError as e:
|
||||
raise sqlite3.DatabaseError(f"{filename}: {e}")
|
||||
91
osxphotos/text_detection.py
Normal file
91
osxphotos/text_detection.py
Normal file
@@ -0,0 +1,91 @@
|
||||
""" Use Apple's Vision Framework via PyObjC to perform text detection on images (macOS 10.15+ only) """
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
import objc
|
||||
import Quartz
|
||||
from Cocoa import NSURL
|
||||
from Foundation import NSDictionary
|
||||
|
||||
# needed to capture system-level stderr
|
||||
from wurlitzer import pipes
|
||||
|
||||
from .utils import _get_os_version
|
||||
|
||||
__all__ = ["detect_text", "make_request_handler"]
|
||||
|
||||
ver, major, minor = _get_os_version()
|
||||
if ver == "10" and int(major) < 15:
|
||||
vision = False
|
||||
else:
|
||||
import Vision
|
||||
|
||||
vision = True
|
||||
|
||||
|
||||
def detect_text(img_path: str, orientation: Optional[int] = None) -> List:
|
||||
"""process image at img_path with VNRecognizeTextRequest and return list of results
|
||||
|
||||
Args:
|
||||
img_path: path to the image file
|
||||
orientation: optional EXIF orientation (if known, passing orientation may improve quality of results)
|
||||
"""
|
||||
if not vision:
|
||||
logging.warning(f"detect_text not implemented for this version of macOS")
|
||||
return []
|
||||
|
||||
with objc.autorelease_pool():
|
||||
input_url = NSURL.fileURLWithPath_(img_path)
|
||||
|
||||
with pipes() as (out, err):
|
||||
# capture stdout and stderr from system calls
|
||||
# otherwise, Quartz.CIImage.imageWithContentsOfURL_
|
||||
# prints to stderr something like:
|
||||
# 2020-09-20 20:55:25.538 python[73042:5650492] Creating client/daemon connection: B8FE995E-3F27-47F4-9FA8-559C615FD774
|
||||
# 2020-09-20 20:55:25.652 python[73042:5650492] Got the query meta data reply for: com.apple.MobileAsset.RawCamera.Camera, response: 0
|
||||
input_image = Quartz.CIImage.imageWithContentsOfURL_(input_url)
|
||||
|
||||
vision_options = NSDictionary.dictionaryWithDictionary_({})
|
||||
if orientation is not None:
|
||||
if not 1 <= orientation <= 8:
|
||||
raise ValueError("orientation must be between 1 and 8")
|
||||
vision_handler = Vision.VNImageRequestHandler.alloc().initWithCIImage_orientation_options_(
|
||||
input_image, orientation, vision_options
|
||||
)
|
||||
else:
|
||||
vision_handler = (
|
||||
Vision.VNImageRequestHandler.alloc().initWithCIImage_options_(
|
||||
input_image, vision_options
|
||||
)
|
||||
)
|
||||
results = []
|
||||
handler = make_request_handler(results)
|
||||
vision_request = (
|
||||
Vision.VNRecognizeTextRequest.alloc().initWithCompletionHandler_(handler)
|
||||
)
|
||||
error = vision_handler.performRequests_error_([vision_request], None)
|
||||
vision_request.dealloc()
|
||||
vision_handler.dealloc()
|
||||
|
||||
for result in results:
|
||||
result[0] = str(result[0])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def make_request_handler(results):
|
||||
"""results: list to store results"""
|
||||
if not isinstance(results, list):
|
||||
raise ValueError("results must be a list")
|
||||
|
||||
def handler(request, error):
|
||||
if error:
|
||||
print(f"Error! {error}")
|
||||
else:
|
||||
observations = request.results()
|
||||
for text_observation in observations:
|
||||
recognized_text = text_observation.topCandidates_(1)[0]
|
||||
results.append([recognized_text.string(), recognized_text.confidence()])
|
||||
|
||||
return handler
|
||||
@@ -238,6 +238,12 @@ To export only photos contained in the album "Summer Vacation":
|
||||
|
||||
`osxphotos export /path/to/export --album "Summer Vacation"`
|
||||
|
||||
In Photos, it's possible to have multiple albums with the same name. In this case, osxphotos would export photos from all albums matching the value passed to `--album`. If you wanted to export only one of the albums and this album is in a folder, the `--regex` option (short for "regular expression"), which does pattern matching, could be used with the `{folder_album}` template to match the specific album. For example, if you had a "Summer Vacation" album inside the folder "2018" and also one with the same name inside the folder "2019", you could export just the album "2018/Summer Vacation" using this command:
|
||||
|
||||
`osxphotos export /path/to/export --regex "2018/Summer Vacation" "{folder_album}"`
|
||||
|
||||
This command matches the pattern "2018/Summer Vacation" against the full folder/album path for every photo.
|
||||
|
||||
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`
|
||||
@@ -272,15 +278,15 @@ For example, to set Finder comment to the photo's title and description:
|
||||
|
||||
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}"`
|
||||
`osxphotos export /path/to/export --xattr-template findercomment "{title,}{title?{descr?{newline},},}{descr,}"`
|
||||
|
||||
Explanation of the template string:
|
||||
|
||||
```txt
|
||||
{title}{title?{descr?{newline},},}{descr}
|
||||
{title,}{title?{descr?{newline},},}{descr,}
|
||||
│ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
└──> insert title │ │ │ │ │
|
||||
└──> insert title (or nothing if no title)
|
||||
│ │ │ │ │ │
|
||||
└───> is there a title?
|
||||
│ │ │ │ │
|
||||
@@ -292,7 +298,8 @@ Explanation of the template string:
|
||||
│ │
|
||||
└───> if title is blank, insert nothing
|
||||
│
|
||||
└───> finally, insert description
|
||||
└───> finally, insert description
|
||||
(or nothing if no 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.
|
||||
@@ -315,6 +322,40 @@ Then the next to you run osxphotos, you can simply do this:
|
||||
|
||||
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.
|
||||
|
||||
### Run commands on exported photos for post-processing
|
||||
|
||||
You can use the `--post-command` option to run one or more commands against exported files. The `--post-command` option takes two arguments: CATEGORY and COMMAND. CATEGORY is a string that describes which category of file to run the command against. The available categories are described in the help text available via: `osxphotos help export`. For example, the `exported` category includes all exported photos and the `skipped` category includes all photos that were skipped when running export with `--update`. COMMAND is an osxphotos template string which will be rendered then passed to the shell for execution.
|
||||
|
||||
For example, the following command generates a log of all exported files and their associated keywords:
|
||||
|
||||
`osxphotos export /path/to/export --post-command exported "echo {shell_quote,{filepath}{comma}{,+keyword,}} >> {shell_quote,{export_dir}/exported.txt}"`
|
||||
|
||||
The special template field `{shell_quote}` ensures a string is properly quoted for execution in the shell. For example, it's possible that a file path or keyword in this example has a space in the value and if not properly quoted, this would cause an error in the execution of the command. When running commands, the template `{filepath}` is set to the full path of the exported file and `{export_dir}` is set to the full path of the base export directory.
|
||||
|
||||
Explanation of the template string:
|
||||
|
||||
```txt
|
||||
{shell_quote,{filepath}{comma}{,+keyword,}}
|
||||
│ │ │ │ │
|
||||
│ │ │ | │
|
||||
└──> quote everything after comma for proper execution in the shell
|
||||
│ │ │ │
|
||||
└───> filepath of the exported file
|
||||
│ │ │
|
||||
└───> insert a comma
|
||||
│ │
|
||||
└───> join the list of keywords together with a ","
|
||||
│
|
||||
└───> if no keywords, insert nothing (empty string: "")
|
||||
```
|
||||
|
||||
Another example: if you had `exiftool` installed and wanted to wipe all metadata from all exported files, you could use the following:
|
||||
|
||||
`osxphotos export /path/to/export --post-command exported "/usr/local/bin/exiftool -all= {filepath|shell_quote}"`
|
||||
|
||||
This command uses the `|shell_quote` template filter instead of the `{shell_quote}` template because the only thing that needs to be quoted is the path to the exported file. Template filters filter the value of the rendered template field. A number of other filters are available and are described in the help text.
|
||||
|
||||
|
||||
### 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!):
|
||||
628
osxphotos/uti.py
Normal file
628
osxphotos/uti.py
Normal file
@@ -0,0 +1,628 @@
|
||||
__all__ = ["get_preferred_uti_extension", "get_uti_for_extension"]
|
||||
""" get UTI for a given file extension and the preferred extension for a given UTI """
|
||||
|
||||
""" Implementation note: runs only on macOS
|
||||
|
||||
On macOS <= 11 (Big Sur), uses objective C CoreServices methods
|
||||
UTTypeCopyPreferredTagWithClass and UTTypeCreatePreferredIdentifierForTag to retrieve the
|
||||
UTI and the extension. These are deprecated in 10.15 (Catalina) and no longer supported on Monterey.
|
||||
|
||||
On Monterey, these calls are replaced with Swift methods that I can't call from python so
|
||||
this code uses a cached dict of UTI values. The code first checks to see if the extension or UTI
|
||||
is available in the cache and if so, returns it. If not, it performs a subprocess call to `mdls` to
|
||||
retrieve the UTI (by creating a temp file with the correct extension) and returns the UTI. This only
|
||||
works for the extension -> UTI lookup. On Monterey, if there is no cached value for UTI -> extension lookup,
|
||||
returns None.
|
||||
|
||||
It's a bit hacky but best I can think of to make this robust on different versions of macOS. PRs welcome.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import CoreServices
|
||||
import objc
|
||||
|
||||
from .utils import _get_os_version
|
||||
|
||||
# cached values of all the UTIs (< 6 chars long) known to my Mac running macOS 10.15.7
|
||||
UTI_CSV = """extension,UTI,preferred_extension,MIME_type
|
||||
c,public.c-source,c,None
|
||||
f,public.fortran-source,f,None
|
||||
h,public.c-header,h,None
|
||||
i,public.c-source.preprocessed,i,None
|
||||
l,public.lex-source,l,None
|
||||
m,public.objective-c-source,m,None
|
||||
o,public.object-code,o,None
|
||||
r,com.apple.rez-source,r,None
|
||||
s,public.assembly-source,s,None
|
||||
y,public.yacc-source,y,None
|
||||
z,public.z-archive,z,application/x-compress
|
||||
aa,com.audible.aa-audiobook,aa,audio/audible
|
||||
ai,com.adobe.illustrator.ai-image,ai,None
|
||||
as,com.apple.applesingle-archive,as,None
|
||||
au,public.au-audio,au,audio/basic
|
||||
bz,public.bzip2-archive,bz2,application/x-bzip2
|
||||
cc,public.c-plus-plus-source,cp,None
|
||||
cp,public.c-plus-plus-source,cp,None
|
||||
dv,public.dv-movie,dv,video/x-dv
|
||||
gz,org.gnu.gnu-zip-archive,gz,application/x-gzip
|
||||
hh,public.c-plus-plus-header,hh,None
|
||||
hp,public.c-plus-plus-header,hh,None
|
||||
ii,public.c-plus-plus-source.preprocessed,ii,None
|
||||
js,com.netscape.javascript-source,js,text/javascript
|
||||
lm,public.lex-source,l,None
|
||||
mi,public.objective-c-source.preprocessed,mi,None
|
||||
mm,public.objective-c-plus-plus-source,mm,None
|
||||
pf,com.apple.colorsync-profile,icc,None
|
||||
pl,public.perl-script,pl,text/x-perl-script
|
||||
pm,public.perl-script,pl,text/x-perl-script
|
||||
ps,com.adobe.postscript,ps,application/postscript
|
||||
py,public.python-script,py,text/x-python-script
|
||||
qt,com.apple.quicktime-movie,mov,video/quicktime
|
||||
ra,com.real.realaudio,ram,audio/vnd.rn-realaudio
|
||||
rb,public.ruby-script,rb,text/x-ruby-script
|
||||
rm,com.real.realmedia,rm,application/vnd.rn-realmedia
|
||||
sh,public.shell-script,sh,None
|
||||
ts,public.mpeg-2-transport-stream,ts,None
|
||||
ul,public.ulaw-audio,ul,None
|
||||
uu,public.uuencoded-archive,uu,text/x-uuencode
|
||||
wm,com.microsoft.windows-media-wm,wm,video/x-ms-wm
|
||||
ym,public.yacc-source,y,None
|
||||
aac,public.aac-audio,aac,audio/aac
|
||||
aae,com.apple.photos.apple-adjustment-envelope,aae,None
|
||||
aaf,org.aafassociation.advanced-authoring-format,aaf,None
|
||||
aax,com.audible.aax-audiobook,aax,audio/vnd.audible.aax
|
||||
abc,public.alembic,abc,None
|
||||
ac3,public.ac3-audio,ac3,audio/ac3
|
||||
ada,public.ada-source,ada,None
|
||||
adb,public.ada-source,ada,None
|
||||
ads,public.ada-source,ada,None
|
||||
aif,public.aifc-audio,aifc,audio/aiff
|
||||
amr,org.3gpp.adaptive-multi-rate-audio,amr,audio/amr
|
||||
app,com.apple.application-bundle,app,None
|
||||
arw,com.sony.arw-raw-image,arw,None
|
||||
asf,com.microsoft.advanced-systems-format,asf,video/x-ms-asf
|
||||
asx,com.microsoft.advanced-stream-redirector,asx,video/x-ms-asx
|
||||
avi,public.avi,avi,video/avi
|
||||
bdm,public.avchd-content,bdm,None
|
||||
bin,com.apple.macbinary-archive,bin,application/macbinary
|
||||
bmp,com.microsoft.bmp,bmp,image/bmp
|
||||
bwf,com.microsoft.waveform-audio,wav,audio/vnd.wave
|
||||
bz2,public.bzip2-archive,bz2,application/x-bzip2
|
||||
caf,com.apple.coreaudio-format,caf,None
|
||||
cdr,com.apple.disk-image-cdr,dvdr,None
|
||||
cel,public.flc-animation,flc,video/flc
|
||||
cer,public.x509-certificate,cer,application/x-x509-ca-cert
|
||||
cpp,public.c-plus-plus-source,cp,None
|
||||
crt,public.x509-certificate,cer,application/x-x509-ca-cert
|
||||
crw,com.canon.crw-raw-image,crw,image/x-canon-crw
|
||||
cr2,com.canon.cr2-raw-image,cr2,None
|
||||
cr3,com.canon.cr3-raw-image,cr3,None
|
||||
csh,public.csh-script,csh,None
|
||||
css,public.css,css,text/css
|
||||
csv,public.comma-separated-values-text,csv,text/csv
|
||||
cxx,public.c-plus-plus-source,cp,None
|
||||
dae,org.khronos.collada.digital-asset-exchange,dae,None
|
||||
dcm,org.nema.dicom,dcm,application/dicom
|
||||
dcr,com.kodak.raw-image,dcr,None
|
||||
dds,com.microsoft.dds,dds,None
|
||||
der,public.x509-certificate,cer,application/x-x509-ca-cert
|
||||
dif,public.dv-movie,dv,video/x-dv
|
||||
dll,com.microsoft.windows-dynamic-link-library,dll,application/x-msdownload
|
||||
dls,public.downloadable-sound,dls,audio/dls
|
||||
dmg,com.apple.disk-image-udif,dmg,None
|
||||
dng,com.adobe.raw-image,dng,image/x-adobe-dng
|
||||
doc,com.microsoft.word.doc,doc,application/msword
|
||||
dot,com.microsoft.word.dot,dot,application/msword
|
||||
dxo,com.dxo.raw-image,dxo,image/x-dxo-dxo
|
||||
ec3,public.enhanced-ac3-audio,eac3,audio/eac3
|
||||
edn,com.adobe.edn,edn,None
|
||||
efx,com.j2.efx-fax,efx,image/efax
|
||||
eml,com.apple.mail.email,eml,message/rfc822
|
||||
eps,com.adobe.encapsulated-postscript,eps,None
|
||||
erf,com.epson.raw-image,erf,image/x-epson-erf
|
||||
etd,com.adobe.etd,etd,None
|
||||
exe,com.microsoft.windows-executable,exe,application/x-msdownload
|
||||
exp,com.apple.symbol-export,exp,None
|
||||
exr,com.ilm.openexr-image,exr,None
|
||||
fdf,com.adobe.fdf,fdf,None
|
||||
fff,com.hasselblad.fff-raw-image,fff,None
|
||||
flc,public.flc-animation,flc,video/flc
|
||||
fli,public.flc-animation,flc,video/flc
|
||||
flv,com.adobe.flash.video,flv,video/x-flv
|
||||
for,public.fortran-source,f,None
|
||||
fpx,com.kodak.flashpix-image,fpx,image/fpx
|
||||
f4a,com.adobe.flash.video,flv,video/x-flv
|
||||
f4b,com.adobe.flash.video,flv,video/x-flv
|
||||
f4p,com.adobe.flash.video,flv,video/x-flv
|
||||
f4v,com.adobe.flash.video,flv,video/x-flv
|
||||
f77,public.fortran-77-source,f77,None
|
||||
f90,public.fortran-90-source,f90,None
|
||||
f95,public.fortran-95-source,f95,None
|
||||
gif,com.compuserve.gif,gif,image/gif
|
||||
hdr,public.radiance,pic,None
|
||||
hpp,public.c-plus-plus-header,hh,None
|
||||
hqx,com.apple.binhex-archive,hqx,application/mac-binhex40
|
||||
htm,public.html,html,text/html
|
||||
hxx,public.c-plus-plus-header,hh,None
|
||||
iba,com.apple.ibooksauthor.pkgbook,iba,None
|
||||
icc,com.apple.colorsync-profile,icc,None
|
||||
icm,com.apple.colorsync-profile,icc,None
|
||||
ico,com.microsoft.ico,ico,image/vnd.microsoft.icon
|
||||
ics,com.apple.ical.ics,ics,text/calendar
|
||||
iig,com.apple.iig-source,iig,None
|
||||
iiq,com.phaseone.raw-image,iiq,None
|
||||
img,com.apple.disk-image-ndif,ndif,None
|
||||
inl,public.c-plus-plus-inline-header,inl,None
|
||||
ipa,com.apple.itunes.ipa,ipa,None
|
||||
ipp,public.c-plus-plus-header,hh,None
|
||||
ips,com.apple.ips,ips,None
|
||||
iso,public.iso-image,iso,None
|
||||
ite,com.apple.tv.ite,ite,None
|
||||
itl,com.apple.itunes.db,itl,None
|
||||
jar,com.sun.java-archive,jar,application/java-archive
|
||||
jav,com.sun.java-source,java,None
|
||||
jfx,com.j2.jfx-fax,jfx,None
|
||||
jpe,public.jpeg,jpeg,image/jpeg
|
||||
jpf,public.jpeg-2000,jp2,image/jp2
|
||||
jpg,public.jpeg,jpeg,image/jpeg
|
||||
jpx,public.jpeg-2000,jp2,image/jp2
|
||||
jp2,public.jpeg-2000,jp2,image/jp2
|
||||
j2c,public.jpeg-2000,jp2,image/jp2
|
||||
j2k,public.jpeg-2000,jp2,image/jp2
|
||||
kar,public.midi-audio,midi,audio/midi
|
||||
key,com.apple.iwork.keynote.key,key,None
|
||||
ksh,public.ksh-script,ksh,None
|
||||
kth,com.apple.iwork.keynote.kth,kth,None
|
||||
ktx,org.khronos.ktx,ktx,None
|
||||
lid,public.dylan-source,dlyan,None
|
||||
lmm,public.lex-source,l,None
|
||||
log,com.apple.log,log,None
|
||||
lpp,public.lex-source,l,None
|
||||
lxx,public.lex-source,l,None
|
||||
mid,public.midi-audio,midi,audio/midi
|
||||
mig,public.mig-source,defs,None
|
||||
mii,public.objective-c-plus-plus-source.preprocessed,mii,None
|
||||
mjs,com.netscape.javascript-source,js,text/javascript
|
||||
mnc,ca.mcgill.mni.bic.mnc,mnc,None
|
||||
mos,com.leafamerica.raw-image,mos,None
|
||||
mov,com.apple.quicktime-movie,mov,video/quicktime
|
||||
mpe,public.mpeg,mpg,video/mpeg
|
||||
mpg,public.mpeg,mpg,video/mpeg
|
||||
mpo,public.mpo-image,mpo,None
|
||||
mp2,public.mp2,mp2,None
|
||||
mp3,public.mp3,mp3,audio/mpeg
|
||||
mp4,public.mpeg-4,mp4,video/mp4
|
||||
mrw,com.konicaminolta.raw-image,mrw,None
|
||||
mts,public.avchd-mpeg-2-transport-stream,mts,None
|
||||
mxf,org.smpte.mxf,mxf,application/mxf
|
||||
m15,public.mpeg,mpg,video/mpeg
|
||||
m2v,public.mpeg-2-video,m2v,video/mpeg2
|
||||
m3u,public.m3u-playlist,m3u,audio/mpegurl
|
||||
m4a,com.apple.m4a-audio,m4a,audio/x-m4a
|
||||
m4b,com.apple.protected-mpeg-4-audio-b,m4b,None
|
||||
m4p,com.apple.protected-mpeg-4-audio,m4p,None
|
||||
m4r,com.apple.mpeg-4-ringtone,m4r,audio/x-m4r
|
||||
m4v,com.apple.m4v-video,m4v,video/x-m4v
|
||||
m75,public.mpeg,mpg,video/mpeg
|
||||
nef,com.nikon.raw-image,nef,None
|
||||
nii,gov.nih.nifti-1,nii,None
|
||||
nrw,com.nikon.nrw-raw-image,nrw,image/x-nikon-nrw
|
||||
obj,public.geometry-definition-format,obj,None
|
||||
odb,org.oasis-open.opendocument.database,odb,application/vnd.oasis.opendocument.database
|
||||
odc,org.oasis-open.opendocument.chart,odc,application/vnd.oasis.opendocument.chart
|
||||
odf,org.oasis-open.opendocument.formula,odf,application/vnd.oasis.opendocument.formula
|
||||
odg,org.oasis-open.opendocument.graphics,odg,application/vnd.oasis.opendocument.graphics
|
||||
odi,org.oasis-open.opendocument.image,odi,application/vnd.oasis.opendocument.image
|
||||
odm,org.oasis-open.opendocument.text-master,odm,application/vnd.oasis.opendocument.text-master
|
||||
odp,org.oasis-open.opendocument.presentation,odp,application/vnd.oasis.opendocument.presentation
|
||||
ods,org.oasis-open.opendocument.spreadsheet,ods,application/vnd.oasis.opendocument.spreadsheet
|
||||
odt,org.oasis-open.opendocument.text,odt,application/vnd.oasis.opendocument.text
|
||||
omf,com.avid.open-media-framework,omf,None
|
||||
orf,com.olympus.raw-image,orf,None
|
||||
otc,public.opentype-collection-font,otc,None
|
||||
otf,public.opentype-font,otf,None
|
||||
otg,org.oasis-open.opendocument.graphics-template,otg,application/vnd.oasis.opendocument.graphics-template
|
||||
oth,org.oasis-open.opendocument.text-web,oth,application/vnd.oasis.opendocument.text-web
|
||||
oti,org.oasis-open.opendocument.image-template,oti,application/vnd.oasis.opendocument.image-template
|
||||
otp,org.oasis-open.opendocument.presentation-template,otp,application/vnd.oasis.opendocument.presentation-template
|
||||
ots,org.oasis-open.opendocument.spreadsheet-template,ots,application/vnd.oasis.opendocument.spreadsheet-template
|
||||
ott,org.oasis-open.opendocument.text-template,ott,application/vnd.oasis.opendocument.text-template
|
||||
pas,public.pascal-source,pas,None
|
||||
pax,public.cpio-archive,cpio,None
|
||||
pbm,public.pbm,pbm,None
|
||||
pch,public.precompiled-c-header,pch,None
|
||||
pct,com.apple.pict,pict,image/pict
|
||||
pdf,com.adobe.pdf,pdf,application/pdf
|
||||
pef,com.pentax.raw-image,pef,None
|
||||
pem,public.x509-certificate,cer,application/x-x509-ca-cert
|
||||
pfa,com.adobe.postscript-pfa-font,pfa,None
|
||||
pfb,com.adobe.postscript-pfb-font,pfb,None
|
||||
pfm,public.pbm,pbm,None
|
||||
pfx,com.rsa.pkcs-12,p12,application/x-pkcs12
|
||||
pgm,public.pbm,pbm,None
|
||||
pgn,com.apple.chess.pgn,pgn,None
|
||||
php,public.php-script,php,text/php
|
||||
ph3,public.php-script,php,text/php
|
||||
ph4,public.php-script,php,text/php
|
||||
pic,com.apple.pict,pict,image/pict
|
||||
pkg,com.apple.installer-package-archive,pkg,None
|
||||
pls,public.pls-playlist,pls,audio/x-scpls
|
||||
ply,public.polygon-file-format,ply,None
|
||||
png,public.png,png,image/png
|
||||
pot,com.microsoft.powerpoint.pot,pot,application/vnd.ms-powerpoint
|
||||
ppm,public.pbm,pbm,None
|
||||
pps,com.microsoft.powerpoint.pps,pps,application/vnd.ms-powerpoint
|
||||
ppt,com.microsoft.powerpoint.ppt,ppt,application/vnd.ms-powerpoint
|
||||
psb,com.adobe.photoshop-large-image,psb,None
|
||||
psd,com.adobe.photoshop-image,psd,image/vnd.adobe.photoshop
|
||||
pvr,public.pvr,pvr,None
|
||||
pvt,com.apple.private.live-photo-bundle,pvt,None
|
||||
pwl,com.leica.pwl-raw-image,pwl,image/x-leica-pwl
|
||||
p12,com.rsa.pkcs-12,p12,application/x-pkcs12
|
||||
qti,com.apple.quicktime-image,qtif,image/x-quicktime
|
||||
qtz,com.apple.quartz-composer-composition,qtz,application/x-quartzcomposer
|
||||
raf,com.fuji.raw-image,raf,None
|
||||
ram,com.real.realaudio,ram,audio/vnd.rn-realaudio
|
||||
raw,com.panasonic.raw-image,raw,None
|
||||
rbw,public.ruby-script,rb,text/x-ruby-script
|
||||
rmp,com.apple.music.rmp-playlist,rmp,application/vnd.rn-rn_music_package
|
||||
rss,public.rss,rss,application/rss+xml
|
||||
rtf,public.rtf,rtf,text/rtf
|
||||
rwl,com.leica.rwl-raw-image,rwl,None
|
||||
rw2,com.panasonic.rw2-raw-image,rw2,None
|
||||
scc,com.scenarist.closed-caption,scc,None
|
||||
scn,com.apple.scenekit.scene,scn,None
|
||||
sda,org.openoffice.graphics,sxd,application/vnd.sun.xml.draw
|
||||
sdc,org.openoffice.spreadsheet,sxc,application/vnd.sun.xml.calc
|
||||
sdd,org.openoffice.presentation,sxi,application/vnd.sun.xml.impress
|
||||
sdp,org.openoffice.presentation,sxi,application/vnd.sun.xml.impress
|
||||
sdv,public.3gpp,3gp,video/3gpp
|
||||
sdw,org.openoffice.text,sxw,application/vnd.sun.xml.writer
|
||||
sd2,com.digidesign.sd2-audio,sd2,None
|
||||
sea,com.stuffit.archive.sit,sit,application/x-stuffit
|
||||
sf2,com.soundblaster.soundfont,sf2,None
|
||||
sgi,com.sgi.sgi-image,sgi,image/sgi
|
||||
sit,com.stuffit.archive.sit,sit,application/x-stuffit
|
||||
slm,com.apple.photos.slow-motion-video-sidecar,slm,None
|
||||
smf,public.midi-audio,midi,audio/midi
|
||||
smi,com.apple.disk-image-smi,smi,None
|
||||
snd,public.au-audio,au,audio/basic
|
||||
spx,com.apple.systemprofiler.document,spx,None
|
||||
srf,com.sony.raw-image,srf,None
|
||||
srw,com.samsung.raw-image,srw,None
|
||||
sr2,com.sony.sr2-raw-image,sr2,image/x-sony-sr2
|
||||
stc,org.openoffice.spreadsheet-template,stc,application/vnd.sun.xml.calc.template
|
||||
std,org.openoffice.graphics-template,std,application/vnd.sun.xml.draw.template
|
||||
sti,org.openoffice.presentation-template,sti,application/vnd.sun.xml.impress.template
|
||||
stl,public.standard-tesselated-geometry-format,stl,None
|
||||
stw,org.openoffice.text-template,stw,application/vnd.sun.xml.writer.template
|
||||
svg,public.svg-image,svg,image/svg+xml
|
||||
sxc,org.openoffice.spreadsheet,sxc,application/vnd.sun.xml.calc
|
||||
sxd,org.openoffice.graphics,sxd,application/vnd.sun.xml.draw
|
||||
sxg,org.openoffice.text-master,sxg,application/vnd.sun.xml.writer.global
|
||||
sxi,org.openoffice.presentation,sxi,application/vnd.sun.xml.impress
|
||||
sxm,org.openoffice.formula,sxm,application/vnd.sun.xml.math
|
||||
sxw,org.openoffice.text,sxw,application/vnd.sun.xml.writer
|
||||
tar,public.tar-archive,tar,application/x-tar
|
||||
tbz,public.tar-bzip2-archive,tbz2,None
|
||||
tga,com.truevision.tga-image,tga,image/targa
|
||||
tgz,org.gnu.gnu-zip-tar-archive,tgz,None
|
||||
tif,public.tiff,tiff,image/tiff
|
||||
tsv,public.tab-separated-values-text,tsv,text/tab-separated-values
|
||||
ttc,public.truetype-collection-font,ttc,None
|
||||
ttf,public.truetype-ttf-font,ttf,None
|
||||
txt,public.plain-text,txt,text/plain
|
||||
ulw,public.ulaw-audio,ul,None
|
||||
url,com.microsoft.internet-shortcut,url,None
|
||||
usd,com.pixar.universal-scene-description,usd,None
|
||||
vcf,public.vcard,vcf,text/directory
|
||||
vcs,com.apple.ical.vcs,vcs,text/x-vcalendar
|
||||
vfw,public.avi,avi,video/avi
|
||||
vtt,org.w3.webvtt,vtt,text/vtt
|
||||
war,com.sun.web-application-archive,war,None
|
||||
wav,com.microsoft.waveform-audio,wav,audio/vnd.wave
|
||||
wax,com.microsoft.windows-media-wax,wax,video/x-ms-wax
|
||||
web,com.getdropbox.dropbox.shortcut,web,None
|
||||
wma,com.microsoft.windows-media-wma,wma,video/x-ms-wma
|
||||
wmp,com.microsoft.windows-media-wmp,wmp,video/x-ms-wmp
|
||||
wmv,com.microsoft.windows-media-wmv,wmv,video/x-ms-wmv
|
||||
wmx,com.microsoft.windows-media-wmx,wmx,video/x-ms-wmx
|
||||
wvx,com.microsoft.windows-media-wvx,wvx,video/x-ms-wvx
|
||||
xar,com.apple.xar-archive,xar,None
|
||||
xbm,public.xbitmap-image,xbm,image/x-xbitmap
|
||||
xfd,public.xfd,xfd,None
|
||||
xht,public.xhtml,xhtml,application/xhtml+xml
|
||||
xip,com.apple.xip-archive,xip,None
|
||||
xla,com.microsoft.excel.xla,xla,None
|
||||
xls,com.microsoft.excel.xls,xls,application/vnd.ms-excel
|
||||
xlt,com.microsoft.excel.xlt,xlt,application/vnd.ms-excel
|
||||
xlw,com.microsoft.excel.xlw,xlw,application/vnd.ms-excel
|
||||
xml,public.xml,xml,application/xml
|
||||
xmp,com.seriflabs.xmp,xmp,application/rdf+xml
|
||||
xpc,com.apple.xpc-service,xpc,None
|
||||
yml,public.yaml,yml,application/x-yaml
|
||||
ymm,public.yacc-source,y,None
|
||||
ypp,public.yacc-source,y,None
|
||||
yxx,public.yacc-source,y,None
|
||||
zip,public.zip-archive,zip,application/zip
|
||||
zsh,public.zsh-script,zsh,None
|
||||
3fr,com.hasselblad.3fr-raw-image,3fr,None
|
||||
3gp,public.3gpp,3gp,video/3gpp
|
||||
3g2,public.3gpp2,3g2,video/3gpp2
|
||||
adts,public.aac-audio,aac,audio/aac
|
||||
ahap,com.apple.haptics.ahap,ahap,None
|
||||
aifc,public.aifc-audio,aifc,audio/aiff
|
||||
aiff,public.aifc-audio,aifc,audio/aiff
|
||||
astc,org.khronos.astc,astc,None
|
||||
avci,public.avci,avci,image/avci
|
||||
avcs,public.avcs,avcs,image/avcs
|
||||
band,com.apple.garageband.project,band,None
|
||||
bash,public.bash-script,bash,None
|
||||
bdmv,public.avchd-content,bdm,None
|
||||
book,com.apple.ibooksauthor.pkgbook,iba,None
|
||||
cdda,public.aifc-audio,aifc,audio/aiff
|
||||
chat,com.apple.ichat.transcript,ichat,None
|
||||
cpgz,com.apple.bom-compressed-cpio,cpgz,None
|
||||
cpio,public.cpio-archive,cpio,None
|
||||
dart,com.apple.disk-image-dart,dart,None
|
||||
dc42,com.apple.disk-image-dc42,dc42,None
|
||||
defs,public.mig-source,defs,None
|
||||
dext,com.apple.driver-extension,dext,None
|
||||
diff,public.patch-file,patch,None
|
||||
dist,com.apple.installer-distribution-package,dist,None
|
||||
docm,org.openxmlformats.wordprocessingml.document.macroenabled,docm,application/vnd.ms-word.document.macroenabled.12
|
||||
docx,org.openxmlformats.wordprocessingml.document,docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
dotm,org.openxmlformats.wordprocessingml.template.macroenabled,dotm,application/vnd.ms-word.template.macroenabled.12
|
||||
dotx,org.openxmlformats.wordprocessingml.template,dotx,application/vnd.openxmlformats-officedocument.wordprocessingml.template
|
||||
dsym,com.apple.xcode.dsym,dsym,None
|
||||
dvdr,com.apple.disk-image-cdr,dvdr,None
|
||||
eac3,public.enhanced-ac3-audio,eac3,audio/eac3
|
||||
emlx,com.apple.mail.emlx,emlx,None
|
||||
enex,com.evernote.enex,enex,None
|
||||
epub,org.idpf.epub-container,epub,application/epub+zip
|
||||
fh10,com.seriflabs.affinity,fh10,None
|
||||
fh11,com.seriflabs.affinity,fh10,None
|
||||
flac,org.xiph.flac,flac,audio/flac
|
||||
fpbf,com.apple.finder.burn-folder,fpbf,None
|
||||
game,com.apple.chess.game,game,None
|
||||
gdoc,com.google.gdoc,gdoc,None
|
||||
gtar,org.gnu.gnu-tar-archive,gtar,application/x-gtar
|
||||
gzip,org.gnu.gnu-zip-archive,gz,application/x-gzip
|
||||
hang,com.apple.hangreport,hang,None
|
||||
heic,public.heic,heic,image/heic
|
||||
heif,public.heif,heif,image/heif
|
||||
html,public.html,html,text/html
|
||||
hvpl,com.apple.music.visual,hvpl,None
|
||||
icbu,com.apple.ical.backup,icbu,None
|
||||
icns,com.apple.icns,icns,None
|
||||
ipsw,com.apple.itunes.ipsw,ipsw,None
|
||||
itlp,com.apple.music.itlp,itlp,None
|
||||
itms,com.apple.itunes.store-url,itms,None
|
||||
java,com.sun.java-source,java,None
|
||||
jnlp,com.sun.java-web-start,jnlp,application/x-java-jnlp-file
|
||||
jpeg,public.jpeg,jpeg,image/jpeg
|
||||
json,public.json,json,application/json
|
||||
latm,public.mp4a-loas,loas,None
|
||||
loas,public.mp4a-loas,loas,None
|
||||
lpdf,com.apple.localized-pdf-bundle,lpdf,None
|
||||
mbox,com.apple.mail.mbox,mbox,None
|
||||
menu,com.apple.menu-extra,menu,None
|
||||
midi,public.midi-audio,midi,audio/midi
|
||||
minc,ca.mcgill.mni.bic.mnc,mnc,None
|
||||
mpeg,public.mpeg,mpg,video/mpeg
|
||||
mpga,public.mp3,mp3,audio/mpeg
|
||||
mpg4,public.mpeg-4,mp4,video/mp4
|
||||
mpkg,com.apple.installer-package-archive,pkg,None
|
||||
m2ts,public.avchd-mpeg-2-transport-stream,mts,None
|
||||
m3u8,public.m3u-playlist,m3u,audio/mpegurl
|
||||
ndif,com.apple.disk-image-ndif,ndif,None
|
||||
note,com.apple.notes.note,note,None
|
||||
php3,public.php-script,php,text/php
|
||||
php4,public.php-script,php,text/php
|
||||
pict,com.apple.pict,pict,image/pict
|
||||
pntg,com.apple.macpaint-image,pntg,None
|
||||
potm,org.openxmlformats.presentationml.template.macroenabled,potm,application/vnd.ms-powerpoint.template.macroenabled.12
|
||||
potx,org.openxmlformats.presentationml.template,potx,application/vnd.openxmlformats-officedocument.presentationml.template
|
||||
ppsm,org.openxmlformats.presentationml.slideshow.macroenabled,ppsm,application/vnd.ms-powerpoint.slideshow.macroenabled.12
|
||||
ppsx,org.openxmlformats.presentationml.slideshow,ppsx,application/vnd.openxmlformats-officedocument.presentationml.slideshow
|
||||
pptm,org.openxmlformats.presentationml.presentation.macroenabled,pptm,application/vnd.ms-powerpoint.presentation.macroenabled.12
|
||||
pptx,org.openxmlformats.presentationml.presentation,pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||
pset,com.apple.pdf-printer-settings,pset,None
|
||||
qtif,com.apple.quicktime-image,qtif,image/x-quicktime
|
||||
rmvb,com.real.realmedia-vbr,rmvb,application/vnd.rn-realmedia-vbr
|
||||
rtfd,com.apple.rtfd,rtfd,None
|
||||
scnz,com.apple.scenekit.scene,scn,None
|
||||
scpt,com.apple.applescript.script,scpt,None
|
||||
shtm,public.html,html,text/html
|
||||
sidx,com.stuffit.archive.sidx,sidx,application/x-stuffitx-index
|
||||
sitx,com.stuffit.archive.sitx,sitx,application/x-stuffitx
|
||||
spin,com.apple.spinreport,spin,None
|
||||
suit,com.apple.font-suitcase,suit,None
|
||||
svgz,public.svg-image,svg,image/svg+xml
|
||||
tbz2,public.tar-bzip2-archive,tbz2,None
|
||||
tcsh,public.tcsh-script,tcsh,None
|
||||
term,com.apple.terminal.session,term,None
|
||||
text,public.plain-text,txt,text/plain
|
||||
tiff,public.tiff,tiff,image/tiff
|
||||
tool,com.apple.terminal.shell-script,command,None
|
||||
udif,com.apple.disk-image-udif,dmg,None
|
||||
ulaw,public.ulaw-audio,ul,None
|
||||
usda,com.pixar.universal-scene-description,usd,None
|
||||
usdc,com.pixar.universal-scene-description,usd,None
|
||||
usdz,com.pixar.universal-scene-description-mobile,usdz,model/vnd.usdz+zip
|
||||
vcal,com.apple.ical.vcs,vcs,text/x-vcalendar
|
||||
wave,com.microsoft.waveform-audio,wav,audio/vnd.wave
|
||||
wdgt,com.apple.dashboard-widget,wdgt,None
|
||||
webp,public.webp,webp,None
|
||||
xfdf,com.adobe.xfdf,xfdf,None
|
||||
xhtm,public.xhtml,xhtml,application/xhtml+xml
|
||||
xlsb,com.microsoft.excel.sheet.binary.macroenabled,xlsb,application/vnd.ms-excel.sheet.binary.macroenabled.12
|
||||
xlsm,org.openxmlformats.spreadsheetml.sheet.macroenabled,xlsm,application/vnd.ms-excel.sheet.macroenabled.12
|
||||
xlsx,org.openxmlformats.spreadsheetml.sheet,xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
xltm,org.openxmlformats.spreadsheetml.template.macroenabled,xltm,application/vnd.ms-excel.template.macroenabled.12
|
||||
xltx,org.openxmlformats.spreadsheetml.template,xltx,application/vnd.openxmlformats-officedocument.spreadsheetml.template
|
||||
yaml,public.yaml,yml,application/x-yaml
|
||||
3gpp,public.3gpp,3gp,video/3gpp
|
||||
3gp2,public.3gpp2,3g2,video/3gpp2
|
||||
abcdg,com.apple.addressbook.group,abcdg,None
|
||||
abcdp,com.apple.addressbook.person,abcdp,None
|
||||
afpub,com.seriflabs.affinitypublisher.document,afpub,None
|
||||
appex,com.apple.application-and-system-extension,appex,None
|
||||
avchd,public.avchd-collection,avchd,None
|
||||
blank,com.apple.preview.blank,blank,None
|
||||
class,com.sun.java-class,class,None
|
||||
crash,com.apple.crashreport,crash,None
|
||||
dfont,com.apple.truetype-datafork-suitcase-font,dfont,None
|
||||
dicom,org.nema.dicom,dcm,application/dicom
|
||||
distz,com.apple.installer-distribution-package,dist,None
|
||||
dlyan,public.dylan-source,dlyan,None
|
||||
dylib,com.apple.mach-o-dylib,dylib,None
|
||||
heics,public.heics,heics,image/heic-sequence
|
||||
heifs,public.heifs,heifs,image/heif-sequence
|
||||
ichat,com.apple.ichat.transcript,ichat,None
|
||||
pages,com.apple.iwork.pages.pages,pages,None
|
||||
panic,com.apple.panicreport,panic,None
|
||||
paper,com.getdropbox.dropbox.paper,paper,None
|
||||
patch,public.patch-file,patch,None
|
||||
phtml,public.php-script,php,text/php
|
||||
plist,com.apple.property-list,plist,None
|
||||
saver,com.apple.systempreference.screen-saver,saver,None
|
||||
scptd,com.apple.applescript.script-bundle,scptd,None
|
||||
sfont,com.apple.cfr-font,sfont,None
|
||||
shtml,public.html,html,text/html
|
||||
swift,public.swift-source,swift,None
|
||||
toast,com.roxio.disk-image-toast,toast,None
|
||||
vcard,public.vcard,vcf,text/directory
|
||||
wdmon,com.apple.wireless-diagnostics.wdmon,wdmon,None
|
||||
xhtml,public.xhtml,xhtml,application/xhtml+xml
|
||||
action,com.apple.automator-action,action,None
|
||||
afploc,com.apple.afp-internet-location,afploc,None
|
||||
"""
|
||||
|
||||
# load CSV separated uti data into dictionaries with key of extension and UTI
|
||||
EXT_UTI_DICT = {}
|
||||
UTI_EXT_DICT = {}
|
||||
|
||||
|
||||
def _load_uti_dict():
|
||||
"""load an initialize the cached UTI and extension dicts"""
|
||||
_reader = csv.DictReader(UTI_CSV.split("\n"), delimiter=",")
|
||||
for row in _reader:
|
||||
EXT_UTI_DICT[row["extension"]] = row["UTI"]
|
||||
UTI_EXT_DICT[row["UTI"]] = row["preferred_extension"]
|
||||
|
||||
|
||||
_load_uti_dict()
|
||||
|
||||
# OS version for determining which methods can be used
|
||||
OS_VER, OS_MAJOR, _ = (int(x) for x in _get_os_version())
|
||||
|
||||
|
||||
def _get_uti_from_mdls(extension):
|
||||
"""use mdls to get the UTI for a given extension on systems that don't support UTTypeCreatePreferredIdentifierForTag
|
||||
Returns: UTI or None if UTI cannot be determined"""
|
||||
|
||||
# mdls -name kMDItemContentType foo.3fr
|
||||
# kMDItemContentType = "com.hasselblad.3fr-raw-image"
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix="." + extension) as temp:
|
||||
output = subprocess.check_output(
|
||||
[
|
||||
"/usr/bin/mdls",
|
||||
"-name",
|
||||
"kMDItemContentType",
|
||||
temp.name,
|
||||
]
|
||||
).splitlines()
|
||||
output = output[0].decode("UTF-8") if output else None
|
||||
if not output:
|
||||
return None
|
||||
|
||||
match = re.match(r'kMDItemContentType\s+\=\s+"(.*)"', output)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_uti_from_ext_dict(ext):
|
||||
try:
|
||||
return EXT_UTI_DICT[ext.lower()]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_ext_from_uti_dict(uti):
|
||||
try:
|
||||
return UTI_EXT_DICT[uti.lower()]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def get_preferred_uti_extension(uti):
|
||||
"""get preferred extension for a UTI type
|
||||
uti: UTI str, e.g. 'public.jpeg'
|
||||
returns: preferred extension as str or None if cannot be determined"""
|
||||
|
||||
if (OS_VER, OS_MAJOR) <= (10, 16):
|
||||
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
|
||||
# deprecated in Catalina+, likely won't work at all on macOS 12
|
||||
with objc.autorelease_pool():
|
||||
extension = CoreServices.UTTypeCopyPreferredTagWithClass(
|
||||
uti, CoreServices.kUTTagClassFilenameExtension
|
||||
)
|
||||
if extension:
|
||||
return extension
|
||||
|
||||
# on MacOS 10.12, HEIC files are not supported and UTTypeCopyPreferredTagWithClass will return None for HEIC
|
||||
if uti == "public.heic":
|
||||
return "HEIC"
|
||||
|
||||
return None
|
||||
|
||||
return _get_ext_from_uti_dict(uti)
|
||||
|
||||
|
||||
def get_uti_for_extension(extension):
|
||||
"""get UTI for a given file extension"""
|
||||
|
||||
if not extension:
|
||||
return None
|
||||
|
||||
# accepts extension with or without leading 0
|
||||
if extension[0] == ".":
|
||||
extension = extension[1:]
|
||||
|
||||
if (OS_VER, OS_MAJOR) <= (10, 16):
|
||||
# https://developer.apple.com/documentation/coreservices/1448939-uttypecreatepreferredidentifierf
|
||||
with objc.autorelease_pool():
|
||||
uti = CoreServices.UTTypeCreatePreferredIdentifierForTag(
|
||||
CoreServices.kUTTagClassFilenameExtension, extension, None
|
||||
)
|
||||
if uti:
|
||||
return uti
|
||||
|
||||
# on MacOS 10.12, HEIC files are not supported and UTTypeCopyPreferredTagWithClass will return None for HEIC
|
||||
if extension.lower() == "heic":
|
||||
return "public.heic"
|
||||
|
||||
return None
|
||||
|
||||
uti = _get_uti_from_ext_dict(extension)
|
||||
if uti:
|
||||
return uti
|
||||
|
||||
uti = _get_uti_from_mdls(extension)
|
||||
if uti:
|
||||
# cache the UTI
|
||||
EXT_UTI_DICT[extension.lower()] = uti
|
||||
UTI_EXT_DICT[uti] = extension.lower()
|
||||
return uti
|
||||
|
||||
return None
|
||||
@@ -1,5 +1,6 @@
|
||||
""" Utility functions used in osxphotos """
|
||||
|
||||
import datetime
|
||||
import fnmatch
|
||||
import glob
|
||||
import importlib
|
||||
@@ -16,13 +17,30 @@ import sys
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from plistlib import load as plistload
|
||||
from typing import Callable
|
||||
from typing import Callable, List, Optional, Union
|
||||
|
||||
import CoreFoundation
|
||||
import CoreServices
|
||||
import objc
|
||||
from Foundation import NSFileManager, NSPredicate, NSString
|
||||
|
||||
from ._constants import UNICODE_FORMAT
|
||||
from .path_utils import sanitize_filestem_with_count
|
||||
|
||||
__all__ = [
|
||||
"dd_to_dms_str",
|
||||
"expand_and_validate_filepath",
|
||||
"get_last_library_path",
|
||||
"get_system_library_path",
|
||||
"increment_filename_with_count",
|
||||
"increment_filename",
|
||||
"lineno",
|
||||
"list_directory",
|
||||
"list_photo_libraries",
|
||||
"load_function",
|
||||
"noop",
|
||||
"normalize_fs_path",
|
||||
"normalize_unicode",
|
||||
]
|
||||
|
||||
_DEBUG = False
|
||||
|
||||
@@ -38,7 +56,7 @@ if not _DEBUG:
|
||||
|
||||
def _get_logger():
|
||||
"""Used only for testing
|
||||
|
||||
|
||||
Returns:
|
||||
logging.Logger object -- logging.Logger object for osxphotos
|
||||
"""
|
||||
@@ -46,7 +64,7 @@ def _get_logger():
|
||||
|
||||
|
||||
def _set_debug(debug):
|
||||
""" Enable or disable debug logging """
|
||||
"""Enable or disable debug logging"""
|
||||
global _DEBUG
|
||||
_DEBUG = debug
|
||||
if debug:
|
||||
@@ -56,18 +74,18 @@ def _set_debug(debug):
|
||||
|
||||
|
||||
def _debug():
|
||||
""" returns True if debugging turned on (via _set_debug), otherwise, false """
|
||||
"""returns True if debugging turned on (via _set_debug), otherwise, false"""
|
||||
return _DEBUG
|
||||
|
||||
|
||||
def noop(*args, **kwargs):
|
||||
""" do nothing (no operation) """
|
||||
"""do nothing (no operation)"""
|
||||
pass
|
||||
|
||||
|
||||
def lineno(filename):
|
||||
""" Returns string with filename and current line number in caller as '(filename): line_num'
|
||||
Will trim filename to just the name, dropping path, if any. """
|
||||
"""Returns string with filename and current line number in caller as '(filename): line_num'
|
||||
Will trim filename to just the name, dropping path, if any."""
|
||||
line = inspect.currentframe().f_back.f_lineno
|
||||
filename = pathlib.Path(filename).name
|
||||
return f"{filename}: {line}"
|
||||
@@ -92,14 +110,14 @@ def _get_os_version():
|
||||
|
||||
|
||||
def _check_file_exists(filename):
|
||||
""" returns true if file exists and is not a directory
|
||||
otherwise returns false """
|
||||
"""returns true if file exists and is not a directory
|
||||
otherwise returns false"""
|
||||
filename = os.path.abspath(filename)
|
||||
return os.path.exists(filename) and not os.path.isdir(filename)
|
||||
|
||||
|
||||
def _get_resource_loc(model_id):
|
||||
""" returns folder_id and file_id needed to find location of edited photo """
|
||||
"""returns folder_id and file_id needed to find location of edited photo"""
|
||||
""" and live photos for version <= Photos 4.0 """
|
||||
# determine folder where Photos stores edited version
|
||||
# edited images are stored in:
|
||||
@@ -117,7 +135,7 @@ def _get_resource_loc(model_id):
|
||||
|
||||
|
||||
def _dd_to_dms(dd):
|
||||
""" convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds """
|
||||
"""convert lat or lon in decimal degrees (dd) to degrees, minutes, seconds"""
|
||||
""" return tuple of int(deg), int(min), float(sec) """
|
||||
dd = float(dd)
|
||||
negative = dd < 0
|
||||
@@ -136,7 +154,7 @@ def _dd_to_dms(dd):
|
||||
|
||||
|
||||
def dd_to_dms_str(lat, lon):
|
||||
""" convert latitude, longitude in degrees to degrees, minutes, seconds as string """
|
||||
"""convert latitude, longitude in degrees to degrees, minutes, seconds as string"""
|
||||
""" lat: latitude in degrees """
|
||||
""" lon: longitude in degrees """
|
||||
""" returns: string tuple in format ("51 deg 30' 12.86\" N", "0 deg 7' 54.50\" W") """
|
||||
@@ -165,7 +183,7 @@ def dd_to_dms_str(lat, lon):
|
||||
|
||||
|
||||
def get_system_library_path():
|
||||
""" return the path to the system Photos library as string """
|
||||
"""return the path to the system Photos library as string"""
|
||||
""" only works on MacOS 10.15 """
|
||||
""" on earlier versions, returns None """
|
||||
_, major, _ = _get_os_version()
|
||||
@@ -190,8 +208,8 @@ def get_system_library_path():
|
||||
|
||||
|
||||
def get_last_library_path():
|
||||
""" returns the path to the last opened Photos library
|
||||
If a library has never been opened, returns None """
|
||||
"""returns the path to the last opened Photos library
|
||||
If a library has never been opened, returns None"""
|
||||
plist_file = pathlib.Path(
|
||||
str(pathlib.Path.home())
|
||||
+ "/Library/Containers/com.apple.Photos/Data/Library/Preferences/com.apple.Photos.plist"
|
||||
@@ -241,14 +259,16 @@ def get_last_library_path():
|
||||
|
||||
|
||||
def list_photo_libraries():
|
||||
""" returns list of Photos libraries found on the system """
|
||||
"""returns list of Photos libraries found on the system"""
|
||||
""" on MacOS < 10.15, this may omit some libraries """
|
||||
|
||||
# On 10.15, mdfind appears to find all libraries
|
||||
# On older MacOS versions, mdfind appears to ignore some libraries
|
||||
# glob to find libraries in ~/Pictures then mdfind to find all the others
|
||||
# TODO: make this more robust
|
||||
lib_list = glob.glob(f"{str(pathlib.Path.home())}/Pictures/*.photoslibrary")
|
||||
lib_list = list_directory(
|
||||
f"{pathlib.Path.home()}/Pictures/", glob="*.photoslibrary"
|
||||
)
|
||||
|
||||
# On older OS, may not get all libraries so make sure we get the last one
|
||||
last_lib = get_last_library_path()
|
||||
@@ -265,50 +285,102 @@ def list_photo_libraries():
|
||||
return lib_list
|
||||
|
||||
|
||||
def get_preferred_uti_extension(uti):
|
||||
""" get preferred extension for a UTI type
|
||||
uti: UTI str, e.g. 'public.jpeg'
|
||||
returns: preferred extension as str """
|
||||
|
||||
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
|
||||
with objc.autorelease_pool():
|
||||
return CoreServices.UTTypeCopyPreferredTagWithClass(
|
||||
uti, CoreServices.kUTTagClassFilenameExtension
|
||||
)
|
||||
def normalize_fs_path(path: str) -> str:
|
||||
"""Normalize filesystem paths with unicode in them"""
|
||||
# macOS HFS+ uses NFD, APFS doesn't normalize but stick with NFD
|
||||
# ref: https://eclecticlight.co/2021/05/08/explainer-unicode-normalization-and-apfs/
|
||||
return unicodedata.normalize("NFD", path)
|
||||
|
||||
|
||||
def findfiles(pattern, path_):
|
||||
"""Returns list of filenames from path_ matched by pattern
|
||||
shell pattern. Matching is case-insensitive.
|
||||
If 'path_' is invalid/doesn't exist, returns []."""
|
||||
if not os.path.isdir(path_):
|
||||
# def findfiles(pattern, path):
|
||||
# """Returns list of filenames from path matched by pattern
|
||||
# shell pattern. Matching is case-insensitive.
|
||||
# If 'path_' is invalid/doesn't exist, returns []."""
|
||||
# if not os.path.isdir(path):
|
||||
# return []
|
||||
|
||||
# # paths need to be normalized for unicode as filesystem returns unicode in NFD form
|
||||
# pattern = normalize_fs_path(pattern)
|
||||
# rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
|
||||
# files = os.listdir(path)
|
||||
# return [name for name in files if rule.match(name)]
|
||||
|
||||
|
||||
def list_directory(
|
||||
directory: Union[str, pathlib.Path],
|
||||
startswith: Optional[str] = None,
|
||||
endswith: Optional[str] = None,
|
||||
contains: Optional[str] = None,
|
||||
glob: Optional[str] = None,
|
||||
include_path: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
) -> List[Union[str, pathlib.Path]]:
|
||||
"""List directory contents and return list of files or directories matching search criteria.
|
||||
Accounts for case-insensitive filesystems, unicode filenames. directory can be a str or a pathlib.Path object.
|
||||
|
||||
Args:
|
||||
directory: directory to search
|
||||
startswith: string to match at start of filename
|
||||
endswith: string to match at end of filename
|
||||
contains: string to match anywhere in filename
|
||||
glob: shell-style glob pattern to match filename
|
||||
include_path: if True, return full path to file
|
||||
case_sensitive: if True, match case-sensitively
|
||||
|
||||
Returns: List of files or directories matching search criteria as either str or pathlib.Path objects depending on the input type;
|
||||
returns empty list if directory is invalid or doesn't exist.
|
||||
|
||||
"""
|
||||
is_pathlib = isinstance(directory, pathlib.Path)
|
||||
if is_pathlib:
|
||||
directory = str(directory)
|
||||
|
||||
if not os.path.isdir(directory):
|
||||
return []
|
||||
# See: https://gist.github.com/techtonik/5694830
|
||||
|
||||
rule = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
|
||||
return [name for name in os.listdir(path_) if rule.match(name)]
|
||||
startswith = normalize_fs_path(startswith) if startswith else None
|
||||
endswith = normalize_fs_path(endswith) if endswith else None
|
||||
contains = normalize_fs_path(contains) if contains else None
|
||||
glob = normalize_fs_path(glob) if glob else None
|
||||
|
||||
files = [normalize_fs_path(f) for f in os.listdir(directory)]
|
||||
if not case_sensitive:
|
||||
files_normalized = {f.lower(): f for f in files}
|
||||
files = [f.lower() for f in files]
|
||||
startswith = startswith.lower() if startswith else None
|
||||
endswith = endswith.lower() if endswith else None
|
||||
contains = contains.lower() if contains else None
|
||||
glob = glob.lower() if glob else None
|
||||
else:
|
||||
files_normalized = {f: f for f in files}
|
||||
|
||||
# TODO: this doesn't always work, still looking for a way to
|
||||
# force Photos to open the library being operated on
|
||||
# def _open_photos_library_applescript(library_path):
|
||||
# """ Force Photos to open a specific library
|
||||
# library_path: path to the Photos library """
|
||||
# open_scpt = AppleScript(
|
||||
# f"""
|
||||
# on openLibrary
|
||||
# tell application "Photos"
|
||||
# open POSIX file "{library_path}"
|
||||
# end tell
|
||||
# end openLibrary
|
||||
# """
|
||||
# )
|
||||
# open_scpt.run()
|
||||
if startswith:
|
||||
files = [f for f in files if f.startswith(startswith)]
|
||||
if endswith:
|
||||
endswith = normalize_fs_path(endswith)
|
||||
files = [f for f in files if f.endswith(endswith)]
|
||||
if contains:
|
||||
contains = normalize_fs_path(contains)
|
||||
files = [f for f in files if contains in f]
|
||||
if glob:
|
||||
glob = normalize_fs_path(glob)
|
||||
flags = re.IGNORECASE if not case_sensitive else 0
|
||||
rule = re.compile(fnmatch.translate(glob), flags)
|
||||
files = [f for f in files if rule.match(f)]
|
||||
|
||||
files = [files_normalized[f] for f in files]
|
||||
|
||||
if include_path:
|
||||
files = [os.path.join(directory, f) for f in files]
|
||||
if is_pathlib:
|
||||
files = [pathlib.Path(f) for f in files]
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def _open_sql_file(dbname):
|
||||
""" opens sqlite file dbname in read-only mode
|
||||
returns tuple of (connection, cursor) """
|
||||
"""opens sqlite file dbname in read-only mode
|
||||
returns tuple of (connection, cursor)"""
|
||||
try:
|
||||
dbpath = pathlib.Path(dbname).resolve()
|
||||
conn = sqlite3.connect(f"{dbpath.as_uri()}?mode=ro", timeout=1, uri=True)
|
||||
@@ -319,9 +391,9 @@ def _open_sql_file(dbname):
|
||||
|
||||
|
||||
def _db_is_locked(dbname):
|
||||
""" check to see if a sqlite3 db is locked
|
||||
returns True if database is locked, otherwise False
|
||||
dbname: name of database to test """
|
||||
"""check to see if a sqlite3 db is locked
|
||||
returns True if database is locked, otherwise False
|
||||
dbname: name of database to test"""
|
||||
|
||||
# first, check to see if lock file exists, if so, assume the file is locked
|
||||
lock_name = f"{dbname}.lock"
|
||||
@@ -344,74 +416,108 @@ def _db_is_locked(dbname):
|
||||
return locked
|
||||
|
||||
|
||||
# OSXPHOTOS_XATTR_UUID = "com.osxphotos.uuid"
|
||||
|
||||
# def get_uuid_for_file(filepath):
|
||||
# """ returns UUID associated with an exported file
|
||||
# filepath: path to exported photo
|
||||
# """
|
||||
# attr = xattr.xattr(filepath)
|
||||
# try:
|
||||
# uuid_bytes = attr[OSXPHOTOS_XATTR_UUID]
|
||||
# uuid_str = uuid_bytes.decode('utf-8')
|
||||
# except KeyError:
|
||||
# uuid_str = None
|
||||
# return uuid_str
|
||||
|
||||
# def set_uuid_for_file(filepath, uuid):
|
||||
# """ sets the UUID associated with an exported file
|
||||
# filepath: path to exported photo
|
||||
# uuid: uuid string for photo
|
||||
# """
|
||||
# if not os.path.exists(filepath):
|
||||
# raise FileNotFoundError(f"Missing file: {filepath}")
|
||||
|
||||
# attr = xattr.xattr(filepath)
|
||||
# uuid_bytes = bytes(uuid, 'utf-8')
|
||||
# attr.set(OSXPHOTOS_XATTR_UUID, uuid_bytes)
|
||||
|
||||
|
||||
def normalize_unicode(value):
|
||||
""" normalize unicode data """
|
||||
if value is not None:
|
||||
if isinstance(value, (tuple, list)):
|
||||
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
|
||||
elif isinstance(value, str):
|
||||
return unicodedata.normalize(UNICODE_FORMAT, value)
|
||||
else:
|
||||
return value
|
||||
else:
|
||||
"""normalize unicode data"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (tuple, list)):
|
||||
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
|
||||
elif isinstance(value, str):
|
||||
return unicodedata.normalize(UNICODE_FORMAT, value)
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def increment_filename(filepath):
|
||||
""" Return filename (1).ext, etc if filename.ext exists
|
||||
def increment_filename_with_count(
|
||||
filepath: Union[str, pathlib.Path],
|
||||
count: int = 0,
|
||||
lock: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> str:
|
||||
"""Return filename (1).ext, etc if filename.ext exists
|
||||
|
||||
If file exists in filename's parent folder with same stem as filename,
|
||||
If file exists in filename's parent folder with same stem as filename,
|
||||
add (1), (2), etc. until a non-existing filename is found.
|
||||
|
||||
Args:
|
||||
filepath: str; full path, including file name
|
||||
filepath: str or pathlib.Path; full path, including file name
|
||||
count: int; starting increment value
|
||||
lock: bool; if True, create a lock file in form .filename.lock to prevent other processes from using the same filename
|
||||
dry_run: bool; if True, don't actually create lock file
|
||||
|
||||
Returns:
|
||||
tuple of new filepath (or same if not incremented), count
|
||||
|
||||
Note: This obviously is subject to race condition so using with caution.
|
||||
"""
|
||||
dest = filepath if isinstance(filepath, pathlib.Path) else pathlib.Path(filepath)
|
||||
dest_files = list_directory(dest.parent, startswith=dest.stem)
|
||||
dest_files = [f.stem.lower() for f in dest_files]
|
||||
dest_new = f"{dest.stem} ({count})" if count else dest.stem
|
||||
dest_new = normalize_fs_path(dest_new)
|
||||
dest_new = sanitize_filestem_with_count(dest_new, dest.suffix)
|
||||
if lock and not dry_run:
|
||||
dest_lock = "." + dest_new + dest.suffix + ".lock"
|
||||
dest_lock = dest.parent / dest_lock
|
||||
else:
|
||||
dest_lock = pathlib.Path("")
|
||||
|
||||
while dest_new.lower() in dest_files or (
|
||||
lock and not dry_run and dest_lock.exists()
|
||||
):
|
||||
count += 1
|
||||
dest_new = normalize_fs_path(f"{dest.stem} ({count})")
|
||||
dest_new = sanitize_filestem_with_count(dest_new, dest.suffix)
|
||||
if lock:
|
||||
dest_lock = "." + dest_new + dest.suffix + ".lock"
|
||||
dest_lock = dest.parent / dest_lock
|
||||
if lock and not dry_run:
|
||||
dest_lock.touch()
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
|
||||
return normalize_fs_path(str(dest)), count
|
||||
|
||||
|
||||
def increment_filename(
|
||||
filepath: Union[str, pathlib.Path], lock: bool = False, dry_run: bool = False
|
||||
) -> str:
|
||||
"""Return filename (1).ext, etc if filename.ext exists
|
||||
|
||||
If file exists in filename's parent folder with same stem as filename,
|
||||
add (1), (2), etc. until a non-existing filename is found.
|
||||
|
||||
Args:
|
||||
filepath: str or pathlib.Path; full path, including file name
|
||||
lock: bool; if True, creates a lock file in form .filename.lock to prevent other processes from using the same filename
|
||||
dry_run: bool; if True, don't actually create lock file
|
||||
|
||||
Returns:
|
||||
new filepath (or same if not incremented)
|
||||
|
||||
Note: This obviously is subject to race condition so using with caution.
|
||||
Note: This obviously is subject to race condition so using with caution but using lock=True reduces the risk of race condition (but lock files must be cleaned up)
|
||||
"""
|
||||
dest = pathlib.Path(str(filepath))
|
||||
count = 1
|
||||
dest_files = findfiles(f"{dest.stem}*", str(dest.parent))
|
||||
dest_files = [pathlib.Path(f).stem.lower() for f in dest_files]
|
||||
dest_new = dest.stem
|
||||
while dest_new.lower() in dest_files:
|
||||
dest_new = f"{dest.stem} ({count})"
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
return str(dest)
|
||||
new_filepath, _ = increment_filename_with_count(
|
||||
filepath, lock=lock, dry_run=dry_run
|
||||
)
|
||||
return new_filepath
|
||||
|
||||
|
||||
def expand_and_validate_filepath(path: str) -> str:
|
||||
"""validate and expand ~ in filepath, also un-escapes spaces
|
||||
|
||||
Returns:
|
||||
expanded path if path is valid file, else None
|
||||
"""
|
||||
|
||||
path = re.sub(r"\\ ", " ", path)
|
||||
path = pathlib.Path(path).expanduser()
|
||||
if path.is_file():
|
||||
return str(path)
|
||||
return None
|
||||
|
||||
|
||||
def load_function(pyfile: str, function_name: str) -> Callable:
|
||||
""" Load function_name from python file pyfile """
|
||||
"""Load function_name from python file pyfile"""
|
||||
module_file = pathlib.Path(pyfile)
|
||||
if not module_file.is_file():
|
||||
raise FileNotFoundError(f"module {pyfile} does not appear to exist")
|
||||
@@ -433,3 +539,9 @@ def load_function(pyfile: str, function_name: str) -> Callable:
|
||||
sys.path = syspath
|
||||
|
||||
return func
|
||||
|
||||
|
||||
def format_sec_to_hhmmss(sec: float) -> str:
|
||||
"""Format seconds to hh:mm:ss"""
|
||||
delta = datetime.timedelta(seconds=sec)
|
||||
return str(delta).split(".")[0]
|
||||
|
||||
235
requirements.txt
235
requirements.txt
@@ -1,211 +1,26 @@
|
||||
aiohttp==4.0.0a1
|
||||
altgraph==0.17
|
||||
ansimarkup==1.4.0
|
||||
appdirs==1.4.3
|
||||
appnope==0.1.0
|
||||
astroid==2.2.5
|
||||
async-timeout==3.0.1
|
||||
atomicwrites==1.3.0
|
||||
attrs==19.1.0
|
||||
backcall==0.1.0
|
||||
better-exceptions-fork==0.2.1.post6
|
||||
bitmath==1.3.3.1
|
||||
bleach==3.3.0
|
||||
Click>=8.0.1,<9.0
|
||||
Mako>=1.1.4,<1.2.0
|
||||
PyYAML>=5.4.1,<6.0.0
|
||||
bitmath>=1.3.3.1,<1.4.0.0
|
||||
bpylist2==3.0.2
|
||||
certifi==2020.4.5.1
|
||||
cffi==1.14.0
|
||||
chardet==3.0.4
|
||||
Click==7.0
|
||||
colorama==0.4.1
|
||||
coverage==4.5.4
|
||||
decorator==4.4.2
|
||||
distlib==0.3.1
|
||||
docutils==0.16
|
||||
entrypoints==0.3
|
||||
filelock==3.0.12
|
||||
idna==2.9
|
||||
importlib-metadata==1.6.0
|
||||
ipykernel==5.1.4
|
||||
ipython==7.13.0
|
||||
ipython-genutils==0.2.0
|
||||
isort==4.3.20
|
||||
jedi==0.16.0
|
||||
jupyter-client==6.1.2
|
||||
jupyter-core==4.6.3
|
||||
keyring==21.2.0
|
||||
lazy-object-proxy==1.4.1
|
||||
loguru==0.2.5
|
||||
macholib==1.14
|
||||
Mako==1.1.1
|
||||
MarkupSafe==1.1.1
|
||||
mccabe==0.6.1
|
||||
modulegraph==0.18
|
||||
more-itertools==7.2.0
|
||||
multidict==4.7.6
|
||||
osxmetadata>=0.99.11
|
||||
packaging==19.0
|
||||
parso==0.6.2
|
||||
pathspec==0.7.0
|
||||
pathvalidate==2.2.1
|
||||
pexpect==4.8.0
|
||||
photoscript==0.1.2
|
||||
pickleshare==0.7.5
|
||||
Pillow==8.1.1
|
||||
pkginfo==1.5.0.1
|
||||
pluggy==0.12.0
|
||||
prompt-toolkit==3.0.4
|
||||
psutil==5.7.0
|
||||
ptyprocess==0.6.0
|
||||
py==1.10.0
|
||||
py2app==0.21
|
||||
pycparser==2.20
|
||||
pyfiglet==0.8.post1
|
||||
Pygments==2.7.4
|
||||
PyInstaller==3.6
|
||||
pyinstaller-setuptools==2019.3
|
||||
pylint==2.3.1
|
||||
pyobjc==6.2.2
|
||||
pyobjc-core==6.2.2
|
||||
pyobjc-framework-Accounts==6.2.2
|
||||
pyobjc-framework-AddressBook==6.2.2
|
||||
pyobjc-framework-AdSupport==6.2.2
|
||||
pyobjc-framework-AppleScriptKit==6.2.2
|
||||
pyobjc-framework-AppleScriptObjC==6.2.2
|
||||
pyobjc-framework-ApplicationServices==6.2.2
|
||||
pyobjc-framework-AuthenticationServices==6.2.2
|
||||
pyobjc-framework-AutomaticAssessmentConfiguration==6.2.2
|
||||
pyobjc-framework-Automator==6.2.2
|
||||
pyobjc-framework-AVFoundation==6.2.2
|
||||
pyobjc-framework-AVKit==6.2.2
|
||||
pyobjc-framework-BusinessChat==6.2.2
|
||||
pyobjc-framework-CalendarStore==6.2.2
|
||||
pyobjc-framework-CFNetwork==6.2.2
|
||||
pyobjc-framework-CloudKit==6.2.2
|
||||
pyobjc-framework-Cocoa==6.2.2
|
||||
pyobjc-framework-Collaboration==6.2.2
|
||||
pyobjc-framework-ColorSync==6.2.2
|
||||
pyobjc-framework-Contacts==6.2.2
|
||||
pyobjc-framework-ContactsUI==6.2.2
|
||||
pyobjc-framework-CoreAudio==6.2.2
|
||||
pyobjc-framework-CoreAudioKit==6.2.2
|
||||
pyobjc-framework-CoreBluetooth==6.2.2
|
||||
pyobjc-framework-CoreData==6.2.2
|
||||
pyobjc-framework-CoreHaptics==6.2.2
|
||||
pyobjc-framework-CoreLocation==6.2.2
|
||||
pyobjc-framework-CoreMedia==6.2.2
|
||||
pyobjc-framework-CoreMediaIO==6.2.2
|
||||
pyobjc-framework-CoreML==6.2.2
|
||||
pyobjc-framework-CoreMotion==6.2.2
|
||||
pyobjc-framework-CoreServices==6.2.2
|
||||
pyobjc-framework-CoreSpotlight==6.2.2
|
||||
pyobjc-framework-CoreText==6.2.2
|
||||
pyobjc-framework-CoreWLAN==6.2.2
|
||||
pyobjc-framework-CryptoTokenKit==6.2.2
|
||||
pyobjc-framework-DeviceCheck==6.2.2
|
||||
pyobjc-framework-DictionaryServices==6.2.2
|
||||
pyobjc-framework-DiscRecording==6.2.2
|
||||
pyobjc-framework-DiscRecordingUI==6.2.2
|
||||
pyobjc-framework-DiskArbitration==6.2.2
|
||||
pyobjc-framework-DVDPlayback==6.2.2
|
||||
pyobjc-framework-EventKit==6.2.2
|
||||
pyobjc-framework-ExceptionHandling==6.2.2
|
||||
pyobjc-framework-ExecutionPolicy==6.2.2
|
||||
pyobjc-framework-ExternalAccessory==6.2.2
|
||||
pyobjc-framework-FileProvider==6.2.2
|
||||
pyobjc-framework-FileProviderUI==6.2.2
|
||||
pyobjc-framework-FinderSync==6.2.2
|
||||
pyobjc-framework-FSEvents==6.2.2
|
||||
pyobjc-framework-GameCenter==6.2.2
|
||||
pyobjc-framework-GameController==6.2.2
|
||||
pyobjc-framework-GameKit==6.2.2
|
||||
pyobjc-framework-GameplayKit==6.2.2
|
||||
pyobjc-framework-ImageCaptureCore==6.2.2
|
||||
pyobjc-framework-IMServicePlugIn==6.2.2
|
||||
pyobjc-framework-InputMethodKit==6.2.2
|
||||
pyobjc-framework-InstallerPlugins==6.2.2
|
||||
pyobjc-framework-InstantMessage==6.2.2
|
||||
pyobjc-framework-Intents==6.2.2
|
||||
pyobjc-framework-IOSurface==6.2.2
|
||||
pyobjc-framework-iTunesLibrary==6.2.2
|
||||
pyobjc-framework-LatentSemanticMapping==6.2.2
|
||||
pyobjc-framework-LaunchServices==6.2.2
|
||||
pyobjc-framework-libdispatch==6.2.2
|
||||
pyobjc-framework-LinkPresentation==6.2.2
|
||||
pyobjc-framework-LocalAuthentication==6.2.2
|
||||
pyobjc-framework-MapKit==6.2.2
|
||||
pyobjc-framework-MediaAccessibility==6.2.2
|
||||
pyobjc-framework-MediaLibrary==6.2.2
|
||||
pyobjc-framework-MediaPlayer==6.2.2
|
||||
pyobjc-framework-MediaToolbox==6.2.2
|
||||
pyobjc-framework-Metal==6.2.2
|
||||
pyobjc-framework-MetalKit==6.2.2
|
||||
pyobjc-framework-ModelIO==6.2.2
|
||||
pyobjc-framework-MultipeerConnectivity==6.2.2
|
||||
pyobjc-framework-NaturalLanguage==6.2.2
|
||||
pyobjc-framework-NetFS==6.2.2
|
||||
pyobjc-framework-Network==6.2.2
|
||||
pyobjc-framework-NetworkExtension==6.2.2
|
||||
pyobjc-framework-NotificationCenter==6.2.2
|
||||
pyobjc-framework-OpenDirectory==6.2.2
|
||||
pyobjc-framework-OSAKit==6.2.2
|
||||
pyobjc-framework-OSLog==6.2.2
|
||||
pyobjc-framework-PencilKit==6.2.2
|
||||
pyobjc-framework-Photos==6.2.2
|
||||
pyobjc-framework-PhotosUI==6.2.2
|
||||
pyobjc-framework-PreferencePanes==6.2.2
|
||||
pyobjc-framework-PubSub==6.2
|
||||
pyobjc-framework-PushKit==6.2.2
|
||||
pyobjc-framework-QTKit==6.0.1
|
||||
pyobjc-framework-Quartz==6.2.2
|
||||
pyobjc-framework-QuickLookThumbnailing==6.2.2
|
||||
pyobjc-framework-SafariServices==6.2.2
|
||||
pyobjc-framework-SceneKit==6.2.2
|
||||
pyobjc-framework-ScreenSaver==6.2.2
|
||||
pyobjc-framework-ScriptingBridge==6.2.2
|
||||
pyobjc-framework-SearchKit==6.2.2
|
||||
pyobjc-framework-Security==6.2.2
|
||||
pyobjc-framework-SecurityFoundation==6.2.2
|
||||
pyobjc-framework-SecurityInterface==6.2.2
|
||||
pyobjc-framework-ServiceManagement==6.2.2
|
||||
pyobjc-framework-Social==6.2.2
|
||||
pyobjc-framework-SoundAnalysis==6.2.2
|
||||
pyobjc-framework-Speech==6.2.2
|
||||
pyobjc-framework-SpriteKit==6.2.2
|
||||
pyobjc-framework-StoreKit==6.2.2
|
||||
pyobjc-framework-SyncServices==6.2.2
|
||||
pyobjc-framework-SystemConfiguration==6.2.2
|
||||
pyobjc-framework-SystemExtensions==6.2.2
|
||||
pyobjc-framework-UserNotifications==6.2.2
|
||||
pyobjc-framework-VideoSubscriberAccount==6.2.2
|
||||
pyobjc-framework-VideoToolbox==6.2.2
|
||||
pyobjc-framework-Vision==6.2.2
|
||||
pyobjc-framework-WebKit==6.2.2
|
||||
pyparsing==2.4.1.1
|
||||
python-dateutil==2.8.1
|
||||
PyYAML==5.4
|
||||
pyzmq==18.1.1
|
||||
readme-renderer==25.0
|
||||
regex==2020.2.20
|
||||
requests==2.23.0
|
||||
requests-toolbelt==0.9.1
|
||||
rich==9.11.1
|
||||
six==1.14.0
|
||||
termcolor==1.1.0
|
||||
textx==2.3.0
|
||||
toml==0.10.0
|
||||
tornado==6.0.4
|
||||
tox==3.19.0
|
||||
tox-conda==0.2.1
|
||||
tqdm==4.45.0
|
||||
traitlets==4.3.3
|
||||
twine==3.1.1
|
||||
typed-ast==1.4.1
|
||||
typing-extensions==3.7.4.2
|
||||
urllib3==1.25.9
|
||||
virtualenv==20.0.30
|
||||
wcwidth==0.1.9
|
||||
webencodings==0.5.1
|
||||
wrapt==1.11.1
|
||||
wurlitzer==2.0.1
|
||||
yarl==1.4.2
|
||||
zipp==0.5.2
|
||||
dataclasses==0.7;python_version<'3.7'
|
||||
more-itertools>=8.8.0,<9.0.0
|
||||
objexplore>=1.5.5,<1.6.0
|
||||
osxmetadata>=0.99.34,<1.0.0
|
||||
pathvalidate>=2.4.1,<2.5.0
|
||||
photoscript>=0.1.4,<0.2.0
|
||||
ptpython>=3.0.20,<3.1.0
|
||||
pyobjc-core>=7.3,<9.0
|
||||
pyobjc-framework-AVFoundation>=7.3,<9.0
|
||||
pyobjc-framework-AppleScriptKit>=7.3,<9.0
|
||||
pyobjc-framework-AppleScriptObjC>=7.3,<9.0
|
||||
pyobjc-framework-Cocoa>=7.3,<9.0
|
||||
pyobjc-framework-CoreServices>=7.2,<9.0
|
||||
pyobjc-framework-Metal>=7.3,<9.0
|
||||
pyobjc-framework-Photos>=7.3,<9.0
|
||||
pyobjc-framework-Quartz>=7.3,<9.0
|
||||
pyobjc-framework-Vision>=7.3,<9.0
|
||||
rich>=10.6.0,<=11.0.0
|
||||
textx>=2.3.0,<2.4.0
|
||||
toml>=0.10.2,<0.11.0
|
||||
wurlitzer>=2.1.0,<2.2.0
|
||||
37
setup.py
37
setup.py
@@ -70,23 +70,36 @@ setup(
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
],
|
||||
install_requires=[
|
||||
"pyobjc>=6.2.2",
|
||||
"Click>=7",
|
||||
"PyYAML>=5.1.2",
|
||||
"Mako>=1.1.1",
|
||||
"bitmath>=1.3.3.1,<1.4.0.0",
|
||||
"bpylist2==3.0.2",
|
||||
"pathvalidate==2.2.1",
|
||||
"Click>=8.0.1,<9.0",
|
||||
"dataclasses==0.7;python_version<'3.7'",
|
||||
"wurlitzer>=2.0.1",
|
||||
"photoscript>=0.1.2",
|
||||
"toml>=0.10.0",
|
||||
"osxmetadata>=0.99.13",
|
||||
"textx==2.3.0",
|
||||
"rich>=9.11.1",
|
||||
"bitmath==1.3.3.1",
|
||||
"Mako>=1.1.4,<1.2.0",
|
||||
"more-itertools>=8.8.0,<9.0.0",
|
||||
"objexplore>=1.5.5,<1.6.0",
|
||||
"osxmetadata>=0.99.34,<1.0.0",
|
||||
"pathvalidate>=2.4.1,<3.0.0",
|
||||
"photoscript>=0.1.4,<0.2.0",
|
||||
"ptpython>=3.0.20,<4.0.0",
|
||||
"pyobjc-core>=7.3,<9.0",
|
||||
"pyobjc-framework-AppleScriptKit>=7.3,<9.0",
|
||||
"pyobjc-framework-AppleScriptObjC>=7.3,<9.0",
|
||||
"pyobjc-framework-AVFoundation>=7.3,<9.0",
|
||||
"pyobjc-framework-Cocoa>=7.3,<9.0",
|
||||
"pyobjc-framework-CoreServices>=7.2,<9.0",
|
||||
"pyobjc-framework-Metal>=7.3,<9.0",
|
||||
"pyobjc-framework-Photos>=7.3,<9.0",
|
||||
"pyobjc-framework-Quartz>=7.3,<9.0",
|
||||
"pyobjc-framework-Vision>=7.3,<9.0",
|
||||
"PyYAML>=5.4.1,<5.5.0",
|
||||
"rich>=10.6.0,<=11.0.0",
|
||||
"textx>=2.3.0,<3.0.0",
|
||||
"toml>=0.10.2,<0.11.0",
|
||||
"wurlitzer>=2.1.0,<3.0.0",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user