Compare commits

..

69 Commits

Author SHA1 Message Date
Rhet Turnbull
64bb07a026 Added additional info to error message for --add-to-album 2021-06-18 14:03:59 -07:00
Rhet Turnbull
f1902b7fd4 Updated README.md [skip ci] 2021-06-18 13:10:06 -07:00
Rhet Turnbull
8e3f8fc7d0 Fix for #471 2021-06-18 13:05:37 -07:00
Rhet Turnbull
c588dcf0ba Updated CHANGELOG.md [skip ci] 2021-06-18 09:15:19 -07:00
Rhet Turnbull
fa29f51aeb Added --post-command, implements #443 2021-06-18 09:04:36 -07:00
Rhet Turnbull
ee0b369086 Added matrix for GitHub action OS 2021-06-18 08:49:39 -07:00
Rhet Turnbull
2fc45c2468 Added macos 10.15 and 11 2021-06-18 08:47:34 -07:00
Rhet Turnbull
15d2f45f0c Added macos 10.15 and 11 2021-06-18 08:46:21 -07:00
Rhet Turnbull
df7b73212f Added macos 10.15 and 11 2021-06-18 08:44:49 -07:00
Rhet Turnbull
5143b165b5 Updated CHANGELOG.md [skip ci] 2021-06-14 06:18:04 -07:00
Rhet Turnbull
10097323e5 Fixed missing more-itertools, #466 2021-06-14 05:16:56 -07:00
Rhet Turnbull
c0bd0ffc9f Added {filepath} template field in prep for --post-command and other goodies 2021-06-13 18:40:45 -07:00
Rhet Turnbull
2cdec3fc78 Refactored PhotoTemplate to support pathlib templates 2021-06-13 09:17:55 -07:00
Rhet Turnbull
1a46cdf63c Updated README.md [skip ci] 2021-06-12 22:08:34 -07:00
Rhet Turnbull
83892e096a Added --duplicate flag to find possible duplicates 2021-06-12 18:31:53 -07:00
Rhet Turnbull
6a0b8b4a3f version bump 2021-06-12 07:21:07 -07:00
Rhet Turnbull
5957fde809 Fixed cli status for --only-new and 0 photos to export 2021-06-12 07:20:39 -07:00
Rhet Turnbull
5711545b81 Fixed test for running in GitHub actions 2021-06-12 06:49:31 -07:00
Rhet Turnbull
0758f84dc4 Cleaned up tests, fixed bug in PhotosDB.query 2021-06-11 23:02:48 -07:00
Rhet Turnbull
4b6c35b5f9 Fix for --convert-to-jpeg with use_photos_export, #460 2021-06-09 04:00:05 -07:00
Rhet Turnbull
d7a9ad1d0a Refactored PhotoInfo.export2 2021-06-06 21:02:22 -07:00
Rhet Turnbull
bb96c35672 Updated test UUIDs 2021-06-06 13:58:44 -07:00
Rhet Turnbull
0880e5b9e8 added pyinstaller 2021-06-05 22:07:31 -07:00
Rhet Turnbull
87af23d98c Added python 3.9 to tests 2021-06-05 11:29:16 -07:00
Rhet Turnbull
61943d051b Updated dependencies to minimize pyobjc requirements 2021-06-05 11:25:41 -07:00
Rhet Turnbull
ef1daf5922 Merge branch 'master' of github.com:RhetTbull/osxphotos 2021-06-05 11:25:17 -07:00
Rhet Turnbull
bb98cff608 Test library update 2021-06-05 11:25:06 -07:00
Rhet Turnbull
620ba9ce03 Added dev_requirements.txt 2021-06-05 11:23:33 -07:00
Rhet Turnbull
86d94ad310 Added dev_requirements.txt 2021-06-05 11:22:37 -07:00
Rhet Turnbull
b8cf21ae82 Added venv [skip ci] 2021-06-04 07:01:33 -07:00
Rhet Turnbull
7accfdb066 Added PhotoInfo.duplicates 2021-06-01 17:32:43 -07:00
Rhet Turnbull
99f4394f8e Added CONTRIBUTING.md 2021-05-30 08:22:02 -07:00
Rhet Turnbull
748aed96cb Updated CHANGELOG.md [skip ci] 2021-05-29 09:08:46 -07:00
Rhet Turnbull
9161739ee6 Updated README.md [skip ci] 2021-05-29 09:05:05 -07:00
Rhet Turnbull
71cf8be94a Updated README.rst for PyPI 2021-05-29 09:03:35 -07:00
Rhet Turnbull
b48133cd83 Fix for #455 2021-05-29 08:51:58 -07:00
Rhet Turnbull
6b5a57fae9 Updated CHANGELOG.md [skip ci] 2021-05-28 09:09:26 -07:00
Rhet Turnbull
24ccf798c2 Updated README.md [skip ci] 2021-05-28 09:05:00 -07:00
Rhet Turnbull
a298772515 Updated tested versions to 11.3 2021-05-28 09:02:37 -07:00
Rhet Turnbull
2d68594b78 Fixes for #454 2021-05-28 08:48:21 -07:00
Rhet Turnbull
b026147c9a Updated README.md [skip ci] 2021-05-23 14:26:01 -07:00
Rhet Turnbull
186a5b77d0 Fixed bug in imageconverter exception handling, closes #440 2021-05-23 14:21:13 -07:00
Rhet Turnbull
518f855a9b PhotoInfo.exiftool now returns ExifToolCaching, closes #450 2021-05-23 14:14:22 -07:00
allcontributors[bot]
0d2067787c docs: add kaduskj as a contributor (#453)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2021-05-23 12:33:08 -07:00
Rhet Turnbull
0448a42329 Updated CHANGELOG.md [skip ci] 2021-05-23 12:30:10 -07:00
Rhet Turnbull
a724e15dd6 Updated README.md [skip ci] 2021-05-23 12:26:51 -07:00
Rhet Turnbull
be8fe9d059 Bug fix for #452 2021-05-23 12:01:36 -07:00
Rhet Turnbull
bd6656107b Updated CHANGELOG.md [skip ci] 2021-05-23 09:46:29 -07:00
Rhet Turnbull
a54e051d41 Updated README.md 2021-05-23 09:37:25 -07:00
Rhet Turnbull
7cde52bf9b Fixed #451, path_derivatives for Photos version <= 4 2021-05-23 09:34:40 -07:00
Rhet Turnbull
96037508c1 README.md update [skip ci] 2021-05-22 10:31:52 -07:00
Rhet Turnbull
9f2268fb2b Cleanup exiftool processes when exiting, #449 2021-05-19 06:16:52 -07:00
Rhet Turnbull
df167c00eb Added osxphotos related template fields, partial fix for #444 2021-05-15 14:46:35 -07:00
Rhet Turnbull
e8f9cda0c6 Update README.md 2021-05-09 18:21:38 -07:00
Rhet Turnbull
d4a951f547 Updated CHANGELOG.md, [skip ci] 2021-05-09 18:17:23 -07:00
Rhet Turnbull
f24e4a7e3c Updated path_derivatives to return results in sorted order (largest to smallest) 2021-05-09 17:52:24 -07:00
Rhet Turnbull
98b84c17f1 Updated CHANGELOG.md, [skip ci] 2021-05-08 23:18:04 -07:00
Rhet Turnbull
78c411a643 Updated docs 2021-05-08 23:10:47 -07:00
Rhet Turnbull
6bdf15b41e Added path_derivatives for Photos <= 4 2021-05-08 22:36:11 -07:00
Rhet Turnbull
a0fcec2a7a Fixed typo in README 2021-05-08 16:08:34 -07:00
Rhet Turnbull
63834ab8ab Added path_derivatives for Photos 5, issue #50 2021-05-08 15:11:59 -07:00
Rhet Turnbull
b23cfa32bb Updated docs 2021-05-07 23:00:17 -07:00
Rhet Turnbull
0e22ce54ab Added date_added for Photos 4, #439 2021-05-07 22:56:03 -07:00
Rhet Turnbull
0f41588701 Added date_added, #439 2021-05-05 06:50:41 -07:00
Rhet Turnbull
442b542794 Added --add-to-album example to README 2021-05-02 13:51:48 -07:00
Rhet Turnbull
88fae81b19 Updated CHANGELOG.md, [skip ci] 2021-05-02 13:51:26 -07:00
Rhet Turnbull
c4fec00f67 Updated docs [skip ci] 2021-05-02 09:15:13 -07:00
Rhet Turnbull
9a0cc3e8fa Added --add-to-album to query 2021-05-02 08:35:37 -07:00
Rhet Turnbull
3ed2362fe3 Updated CHANGELOG.md, [skip ci] 2021-05-01 21:26:49 -07:00
135 changed files with 3320 additions and 7244 deletions

View File

@@ -213,6 +213,15 @@
"example",
"ideas"
]
},
{
"login": "kaduskj",
"name": "kaduskj",
"avatar_url": "https://avatars.githubusercontent.com/u/983067?v=4",
"profile": "https://github.com/kaduskj",
"contributions": [
"bug"
]
}
],
"contributorsPerLine": 7,

View File

@@ -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/

1
.gitignore vendored
View File

@@ -15,3 +15,4 @@ osxphotos.egg-info/
cli.spec
*.pyc
docsrc/_build/
venv/

File diff suppressed because it is too large Load Diff

15
CONTRIBUTING.md Normal file
View 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.

438
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,7 @@ 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).
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.
@@ -146,6 +145,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
dev_requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
pytest==6.2.4
pytest-mock==3.6.1
Sphinx==4.0.2
sphinx-rtd-theme==0.5.2
wheel==0.36.2
twine==3.4.1
pyinstaller==4.3

View File

@@ -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: 5342827b9d06cfc608d1a286ed0f5c3f
config: 210ecd9d654dea5d4c21627449ca1d63
tags: 645f666f9bcd5a90fca523b33c5a78b7

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overview: module code &#8212; osxphotos 0.42.14 documentation</title>
<title>Overview: module code &#8212; 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>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photoinfo._photoinfo_export &#8212; osxphotos 0.41.10 documentation</title>
<title>osxphotos.photoinfo._photoinfo_export &#8212; osxphotos 0.42.17 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>
@@ -122,6 +122,9 @@
<span class="n">xattr_skipped</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span>
<span class="n">deleted_files</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span>
<span class="n">deleted_directories</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span>
<span class="n">exported_album</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span>
<span class="n">skipped_album</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span>
<span class="n">missing_album</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span>
<span class="p">):</span>
<span class="bp">self</span><span class="o">.</span><span class="n">exported</span> <span class="o">=</span> <span class="n">exported</span> <span class="ow">or</span> <span class="p">[]</span>
<span class="bp">self</span><span class="o">.</span><span class="n">new</span> <span class="o">=</span> <span class="n">new</span> <span class="ow">or</span> <span class="p">[]</span>
@@ -144,6 +147,9 @@
<span class="bp">self</span><span class="o">.</span><span class="n">xattr_skipped</span> <span class="o">=</span> <span class="n">xattr_skipped</span> <span class="ow">or</span> <span class="p">[]</span>
<span class="bp">self</span><span class="o">.</span><span class="n">deleted_files</span> <span class="o">=</span> <span class="n">deleted_files</span> <span class="ow">or</span> <span class="p">[]</span>
<span class="bp">self</span><span class="o">.</span><span class="n">deleted_directories</span> <span class="o">=</span> <span class="n">deleted_directories</span> <span class="ow">or</span> <span class="p">[]</span>
<span class="bp">self</span><span class="o">.</span><span class="n">exported_album</span> <span class="o">=</span> <span class="n">exported_album</span> <span class="ow">or</span> <span class="p">[]</span>
<span class="bp">self</span><span class="o">.</span><span class="n">skipped_album</span> <span class="o">=</span> <span class="n">skipped_album</span> <span class="ow">or</span> <span class="p">[]</span>
<span class="bp">self</span><span class="o">.</span><span class="n">missing_album</span> <span class="o">=</span> <span class="n">missing_album</span> <span class="ow">or</span> <span class="p">[]</span>
<span class="k">def</span> <span class="nf">all_files</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="sd">&quot;&quot;&quot; return all filenames contained in results &quot;&quot;&quot;</span>
@@ -190,6 +196,10 @@
<span class="bp">self</span><span class="o">.</span><span class="n">exiftool_error</span> <span class="o">+=</span> <span class="n">other</span><span class="o">.</span><span class="n">exiftool_error</span>
<span class="bp">self</span><span class="o">.</span><span class="n">deleted_files</span> <span class="o">+=</span> <span class="n">other</span><span class="o">.</span><span class="n">deleted_files</span>
<span class="bp">self</span><span class="o">.</span><span class="n">deleted_directories</span> <span class="o">+=</span> <span class="n">other</span><span class="o">.</span><span class="n">deleted_directories</span>
<span class="bp">self</span><span class="o">.</span><span class="n">exported_album</span> <span class="o">+=</span> <span class="n">other</span><span class="o">.</span><span class="n">exported_album</span>
<span class="bp">self</span><span class="o">.</span><span class="n">skipped_album</span> <span class="o">+=</span> <span class="n">other</span><span class="o">.</span><span class="n">skipped_album</span>
<span class="bp">self</span><span class="o">.</span><span class="n">missing_album</span> <span class="o">+=</span> <span class="n">other</span><span class="o">.</span><span class="n">missing_album</span>
<span class="k">return</span> <span class="bp">self</span>
<span class="k">def</span> <span class="fm">__str__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
@@ -214,6 +224,9 @@
<span class="o">+</span> <span class="sa">f</span><span class="s2">&quot;,exiftool_error=</span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">exiftool_error</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="o">+</span> <span class="sa">f</span><span class="s2">&quot;,deleted_files=</span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">deleted_files</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="o">+</span> <span class="sa">f</span><span class="s2">&quot;,deleted_directories=</span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">deleted_directories</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="o">+</span> <span class="sa">f</span><span class="s2">&quot;,exported_album=</span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">exported_album</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="o">+</span> <span class="sa">f</span><span class="s2">&quot;,skipped_album=</span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">skipped_album</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="o">+</span> <span class="sa">f</span><span class="s2">&quot;,missing_album=</span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">missing_album</span><span class="si">}</span><span class="s2">&quot;</span>
<span class="o">+</span> <span class="s2">&quot;)&quot;</span>
<span class="p">)</span>
@@ -654,7 +667,11 @@
<span class="p">)</span>
<span class="n">edited_name</span> <span class="o">=</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">path_edited</span><span class="p">)</span><span class="o">.</span><span class="n">name</span>
<span class="n">edited_suffix</span> <span class="o">=</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="n">edited_name</span><span class="p">)</span><span class="o">.</span><span class="n">suffix</span>
<span class="n">fname</span> <span class="o">=</span> <span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">original_filename</span><span class="p">)</span><span class="o">.</span><span class="n">stem</span> <span class="o">+</span> <span class="n">edited_identifier</span> <span class="o">+</span> <span class="n">edited_suffix</span>
<span class="n">fname</span> <span class="o">=</span> <span class="p">(</span>
<span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">original_filename</span><span class="p">)</span><span class="o">.</span><span class="n">stem</span>
<span class="o">+</span> <span class="n">edited_identifier</span>
<span class="o">+</span> <span class="n">edited_suffix</span>
<span class="p">)</span>
<span class="k">else</span><span class="p">:</span>
<span class="n">fname</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">original_filename</span>
@@ -1687,7 +1704,7 @@
<span class="n">exif</span><span class="p">[</span><span class="s2">&quot;QuickTime:ModifyDate&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="n">datetime_tz_to_utc</span><span class="p">(</span>
<span class="bp">self</span><span class="o">.</span><span class="n">date_modified</span>
<span class="p">)</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">&quot;%Y:%m:</span><span class="si">%d</span><span class="s2"> %H:%M:%S&quot;</span><span class="p">)</span>
<span class="c1"># remove any new lines in any fields</span>
<span class="k">for</span> <span class="n">field</span><span class="p">,</span> <span class="n">val</span> <span class="ow">in</span> <span class="n">exif</span><span class="o">.</span><span class="n">items</span><span class="p">():</span>
<span class="k">if</span> <span class="nb">type</span><span class="p">(</span><span class="n">val</span><span class="p">)</span> <span class="o">==</span> <span class="nb">str</span><span class="p">:</span>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photoinfo.photoinfo &#8212; osxphotos 0.42.12 documentation</title>
<title>osxphotos.photoinfo.photoinfo &#8212; 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>
@@ -521,7 +521,7 @@
<span class="k">try</span><span class="p">:</span>
<span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">_burst_album_info</span>
<span class="k">except</span> <span class="ne">AttributeError</span><span class="p">:</span>
<span class="n">burst_album_info</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">album_info</span><span class="p">)</span>
<span class="n">burst_album_info</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">album_info</span><span class="p">)</span>
<span class="k">for</span> <span class="n">photo</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">burst_photos</span><span class="p">:</span>
<span class="k">if</span> <span class="n">photo</span><span class="o">.</span><span class="n">burst_key</span><span class="p">:</span>
<span class="n">burst_album_info</span><span class="o">.</span><span class="n">extend</span><span class="p">(</span><span class="n">photo</span><span class="o">.</span><span class="n">album_info</span><span class="p">)</span>
@@ -637,6 +637,23 @@
<span class="k">else</span><span class="p">:</span>
<span class="k">return</span> <span class="kc">None</span>
<span class="nd">@property</span>
<span class="k">def</span> <span class="nf">date_added</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="sd">&quot;&quot;&quot; Date photo was added to the database &quot;&quot;&quot;</span>
<span class="k">try</span><span class="p">:</span>
<span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">_date_added</span>
<span class="k">except</span> <span class="ne">AttributeError</span><span class="p">:</span>
<span class="n">added_date</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_info</span><span class="p">[</span><span class="s2">&quot;added_date&quot;</span><span class="p">]</span>
<span class="k">if</span> <span class="n">added_date</span><span class="p">:</span>
<span class="n">seconds</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_info</span><span class="p">[</span><span class="s2">&quot;imageTimeZoneOffsetSeconds&quot;</span><span class="p">]</span> <span class="ow">or</span> <span class="mi">0</span>
<span class="n">delta</span> <span class="o">=</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">seconds</span><span class="o">=</span><span class="n">seconds</span><span class="p">)</span>
<span class="n">tz</span> <span class="o">=</span> <span class="n">timezone</span><span class="p">(</span><span class="n">delta</span><span class="p">)</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_date_added</span> <span class="o">=</span> <span class="n">added_date</span><span class="o">.</span><span class="n">astimezone</span><span class="p">(</span><span class="n">tz</span><span class="o">=</span><span class="n">tz</span><span class="p">)</span>
<span class="k">else</span><span class="p">:</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_date_added</span> <span class="o">=</span> <span class="kc">None</span>
<span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">_date_added</span>
<span class="nd">@property</span>
<span class="k">def</span> <span class="nf">location</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="sd">&quot;&quot;&quot; returns (latitude, longitude) as float in degrees or None &quot;&quot;&quot;</span>
@@ -834,6 +851,60 @@
<span class="k">return</span> <span class="n">photopath</span>
<span class="nd">@property</span>
<span class="k">def</span> <span class="nf">path_derivatives</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="sd">&quot;&quot;&quot; Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first) &quot;&quot;&quot;</span>
<span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">_db</span><span class="o">.</span><span class="n">_db_version</span> <span class="o">&lt;=</span> <span class="n">_PHOTOS_4_VERSION</span><span class="p">:</span>
<span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">_path_derivatives_4</span><span class="p">()</span>
<span class="n">directory</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_uuid</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="c1"># first char of uuid</span>
<span class="n">derivative_path</span> <span class="o">=</span> <span class="p">(</span>
<span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_db</span><span class="o">.</span><span class="n">_library_path</span><span class="p">)</span>
<span class="o">/</span> <span class="s2">&quot;resources&quot;</span>
<span class="o">/</span> <span class="s2">&quot;derivatives&quot;</span>
<span class="o">/</span> <span class="n">directory</span>
<span class="p">)</span>
<span class="n">files</span> <span class="o">=</span> <span class="n">derivative_path</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="bp">self</span><span class="o">.</span><span class="n">uuid</span><span class="si">}</span><span class="s2">*.*&quot;</span><span class="p">)</span>
<span class="n">files</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">files</span><span class="p">,</span> <span class="n">reverse</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">f</span><span class="p">:</span> <span class="n">f</span><span class="o">.</span><span class="n">stat</span><span class="p">()</span><span class="o">.</span><span class="n">st_size</span><span class="p">)</span>
<span class="c1"># return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)</span>
<span class="k">return</span> <span class="p">[</span><span class="nb">str</span><span class="p">(</span><span class="n">filename</span><span class="p">)</span> <span class="k">for</span> <span class="n">filename</span> <span class="ow">in</span> <span class="n">files</span> <span class="k">if</span> <span class="n">filename</span><span class="o">.</span><span class="n">suffix</span> <span class="o">!=</span> <span class="s2">&quot;.THM&quot;</span><span class="p">]</span>
<span class="k">def</span> <span class="nf">_path_derivatives_4</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="sd">&quot;&quot;&quot; Return paths to all derivative (preview) files for Photos &lt;= 4&quot;&quot;&quot;</span>
<span class="n">modelid</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_info</span><span class="p">[</span><span class="s2">&quot;masterModelID&quot;</span><span class="p">]</span>
<span class="k">if</span> <span class="n">modelid</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
<span class="k">return</span> <span class="p">[]</span>
<span class="n">folder_id</span><span class="p">,</span> <span class="n">file_id</span> <span class="o">=</span> <span class="n">_get_resource_loc</span><span class="p">(</span><span class="n">modelid</span><span class="p">)</span>
<span class="n">derivatives_root</span> <span class="o">=</span> <span class="p">(</span>
<span class="n">pathlib</span><span class="o">.</span><span class="n">Path</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_db</span><span class="o">.</span><span class="n">_library_path</span><span class="p">)</span>
<span class="o">/</span> <span class="s2">&quot;resources&quot;</span>
<span class="o">/</span> <span class="s2">&quot;proxies&quot;</span>
<span class="o">/</span> <span class="s2">&quot;derivatives&quot;</span>
<span class="o">/</span> <span class="n">folder_id</span>
<span class="p">)</span>
<span class="c1"># photos appears to usually be in &quot;00&quot; subfolder but</span>
<span class="c1"># could be elsewhere--I haven&#39;t figured out this logic yet</span>
<span class="c1"># first see if it&#39;s in 00</span>
<span class="n">derivatives_path</span> <span class="o">=</span> <span class="n">derivatives_root</span> <span class="o">/</span> <span class="s2">&quot;00&quot;</span> <span class="o">/</span> <span class="n">file_id</span>
<span class="k">if</span> <span class="n">derivatives_path</span><span class="o">.</span><span class="n">is_dir</span><span class="p">():</span>
<span class="n">files</span> <span class="o">=</span> <span class="n">derivatives_path</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="s2">&quot;*&quot;</span><span class="p">)</span>
<span class="n">files</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">files</span><span class="p">,</span> <span class="n">reverse</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">f</span><span class="p">:</span> <span class="n">f</span><span class="o">.</span><span class="n">stat</span><span class="p">()</span><span class="o">.</span><span class="n">st_size</span><span class="p">)</span>
<span class="k">return</span> <span class="p">[</span><span class="nb">str</span><span class="p">(</span><span class="n">filename</span><span class="p">)</span> <span class="k">for</span> <span class="n">filename</span> <span class="ow">in</span> <span class="n">files</span><span class="p">]</span>
<span class="c1"># didn&#39;t find derivatives path</span>
<span class="k">for</span> <span class="n">subdir</span> <span class="ow">in</span> <span class="n">derivatives_root</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="s2">&quot;*&quot;</span><span class="p">):</span>
<span class="k">if</span> <span class="n">subdir</span><span class="o">.</span><span class="n">is_dir</span><span class="p">():</span>
<span class="n">derivatives_path</span> <span class="o">=</span> <span class="n">derivatives_root</span> <span class="o">/</span> <span class="n">subdir</span> <span class="o">/</span> <span class="n">file_id</span>
<span class="k">if</span> <span class="n">derivatives_path</span><span class="o">.</span><span class="n">is_dir</span><span class="p">():</span>
<span class="n">files</span> <span class="o">=</span> <span class="n">derivatives_path</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="s2">&quot;*&quot;</span><span class="p">)</span>
<span class="n">files</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">files</span><span class="p">,</span> <span class="n">reverse</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">f</span><span class="p">:</span> <span class="n">f</span><span class="o">.</span><span class="n">stat</span><span class="p">()</span><span class="o">.</span><span class="n">st_size</span><span class="p">)</span>
<span class="k">return</span> <span class="p">[</span><span class="nb">str</span><span class="p">(</span><span class="n">filename</span><span class="p">)</span> <span class="k">for</span> <span class="n">filename</span> <span class="ow">in</span> <span class="n">files</span><span class="p">]</span>
<span class="c1"># didn&#39;t find a derivatives path</span>
<span class="k">return</span> <span class="p">[]</span>
<span class="nd">@property</span>
<span class="k">def</span> <span class="nf">panorama</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
<span class="sd">&quot;&quot;&quot; Returns True if photo is a panorama, otherwise False &quot;&quot;&quot;</span>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos.photosdb.photosdb &#8212; osxphotos 0.42.14 documentation</title>
<title>osxphotos.photosdb.photosdb &#8212; osxphotos 0.42.19 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>
@@ -933,7 +933,8 @@
<span class="sd"> RKVersion.subType,</span>
<span class="sd"> RKVersion.inTrashDate,</span>
<span class="sd"> RKVersion.showInLibrary,</span>
<span class="sd"> RKMaster.fileIsReference</span>
<span class="sd"> RKMaster.fileIsReference,</span>
<span class="sd"> RKMaster.importGroupUuid</span>
<span class="sd"> FROM RKVersion, RKMaster</span>
<span class="sd"> WHERE RKVersion.masterUuid = RKMaster.uuid&quot;&quot;&quot;</span>
<span class="p">)</span>
@@ -964,7 +965,8 @@
<span class="sd"> RKVersion.subType,</span>
<span class="sd"> RKVersion.inTrashDate,</span>
<span class="sd"> RKVersion.showInLibrary,</span>
<span class="sd"> RKMaster.fileIsReference</span>
<span class="sd"> RKMaster.fileIsReference,</span>
<span class="sd"> RKMaster.importGroupUuid</span>
<span class="sd"> FROM RKVersion, RKMaster</span>
<span class="sd"> WHERE RKVersion.masterUuid = RKMaster.uuid&quot;&quot;&quot;</span>
<span class="p">)</span>
@@ -1014,6 +1016,7 @@
<span class="c1"># 41 RKVersion.inTrashDate</span>
<span class="c1"># 42 RKVersion.showInLibrary -- is item visible in library (e.g. non-selected burst images are not visible)</span>
<span class="c1"># 43 RKMaster.fileIsReference -- file is reference (imported without copying to Photos library)</span>
<span class="c1"># 44 RKMaster.importGroupUuid -- to get date added from RKImportGroup</span>
<span class="k">for</span> <span class="n">row</span> <span class="ow">in</span> <span class="n">c</span><span class="p">:</span>
<span class="n">uuid</span> <span class="o">=</span> <span class="n">row</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
@@ -1207,7 +1210,7 @@
<span class="c1"># import session not yet handled for Photos 4</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">&quot;import_session&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">&quot;import_uuid&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">&quot;import_uuid&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="n">row</span><span class="p">[</span><span class="mi">44</span><span class="p">]</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">&quot;fok_import_session&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>
<span class="c1"># get additional details from RKMaster, needed for RAW processing</span>
@@ -1397,11 +1400,17 @@
<span class="c1"># get the place data</span>
<span class="n">place_data</span> <span class="o">=</span> <span class="n">c</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span>
<span class="s2">&quot;SELECT modelID, defaultName, type, area &quot;</span> <span class="s2">&quot;FROM RKPlace &quot;</span>
<span class="s2">&quot;SELECT modelID, defaultName, type, area FROM RKPlace&quot;</span>
<span class="p">)</span><span class="o">.</span><span class="n">fetchall</span><span class="p">()</span>
<span class="n">places</span> <span class="o">=</span> <span class="p">{</span><span class="n">p</span><span class="p">[</span><span class="mi">0</span><span class="p">]:</span> <span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">place_data</span><span class="p">}</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_db_places</span> <span class="o">=</span> <span class="n">places</span>
<span class="c1"># get import data</span>
<span class="n">import_data</span> <span class="o">=</span> <span class="n">c</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span>
<span class="s2">&quot;SELECT modelID, uuid, name, importDate from RKImportGroup&quot;</span>
<span class="p">)</span><span class="o">.</span><span class="n">fetchall</span><span class="p">()</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_db_import_group</span> <span class="o">=</span> <span class="p">{</span><span class="n">i</span><span class="p">[</span><span class="mi">1</span><span class="p">]:</span> <span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">import_data</span><span class="p">}</span>
<span class="k">for</span> <span class="n">uuid</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">:</span>
<span class="c1"># get placeId which is then used to lookup defaultName</span>
<span class="n">place_ids_query</span> <span class="o">=</span> <span class="n">c</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span>
@@ -1435,6 +1444,17 @@
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">&quot;placeNames&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="n">place_names</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">&quot;reverse_geolocation&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span> <span class="c1"># Photos 5</span>
<span class="c1"># add date added</span>
<span class="k">try</span><span class="p">:</span>
<span class="n">import_session</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_db_import_group</span><span class="p">[</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">&quot;import_uuid&quot;</span><span class="p">]</span>
<span class="p">]</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">&quot;added_date&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="n">datetime</span><span class="o">.</span><span class="n">fromtimestamp</span><span class="p">(</span>
<span class="n">import_session</span><span class="p">[</span><span class="mi">3</span><span class="p">]</span> <span class="o">+</span> <span class="n">TIME_DELTA</span>
<span class="p">)</span>
<span class="k">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">TypeError</span><span class="p">,</span> <span class="ne">KeyError</span><span class="p">):</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_dbphotos</span><span class="p">[</span><span class="n">uuid</span><span class="p">][</span><span class="s2">&quot;added_date&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">(</span><span class="mi">1970</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
<span class="c1"># build album_titles dictionary</span>
<span class="k">for</span> <span class="n">album_id</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_dbalbum_details</span><span class="p">:</span>
<span class="n">title</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_dbalbum_details</span><span class="p">[</span><span class="n">album_id</span><span class="p">][</span><span class="s2">&quot;title&quot;</span><span class="p">]</span>
@@ -1900,7 +1920,8 @@
<span class="s2"> </span><span class="si">{</span><span class="n">asset_table</span><span class="si">}</span><span class="s2">.ZADJUSTMENTTIMESTAMP,</span>
<span class="s2"> </span><span class="si">{</span><span class="n">asset_table</span><span class="si">}</span><span class="s2">.ZVISIBILITYSTATE,</span>
<span class="s2"> </span><span class="si">{</span><span class="n">asset_table</span><span class="si">}</span><span class="s2">.ZTRASHEDDATE,</span>
<span class="s2"> </span><span class="si">{</span><span class="n">asset_table</span><span class="si">}</span><span class="s2">.ZSAVEDASSETTYPE</span>
<span class="s2"> </span><span class="si">{</span><span class="n">asset_table</span><span class="si">}</span><span class="s2">.ZSAVEDASSETTYPE,</span>
<span class="s2"> </span><span class="si">{</span><span class="n">asset_table</span><span class="si">}</span><span class="s2">.ZADDEDDATE</span>
<span class="s2"> FROM </span><span class="si">{</span><span class="n">asset_table</span><span class="si">}</span><span class="s2"> </span>
<span class="s2"> JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = </span><span class="si">{</span><span class="n">asset_table</span><span class="si">}</span><span class="s2">.Z_PK </span>
<span class="s2"> ORDER BY </span><span class="si">{</span><span class="n">asset_table</span><span class="si">}</span><span class="s2">.ZUUID &quot;&quot;&quot;</span>
@@ -1948,6 +1969,7 @@
<span class="c1"># 38 ZGENERICASSET.ZVISIBILITYSTATE -- 0 if visible, 2 if not (e.g. a burst image)</span>
<span class="c1"># 39 ZGENERICASSET.ZTRASHEDDATE -- date item placed in the trash or null if not in trash</span>
<span class="c1"># 40 ZGENERICASSET.ZSAVEDASSETTYPE -- how item imported</span>
<span class="c1"># 41 ZGENERICASSET.ZADDEDDATE -- date item added to the library</span>
<span class="k">for</span> <span class="n">row</span> <span class="ow">in</span> <span class="n">c</span><span class="p">:</span>
<span class="n">uuid</span> <span class="o">=</span> <span class="n">row</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
@@ -2127,6 +2149,11 @@
<span class="n">info</span><span class="p">[</span><span class="s2">&quot;saved_asset_type&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="n">row</span><span class="p">[</span><span class="mi">40</span><span class="p">]</span>
<span class="n">info</span><span class="p">[</span><span class="s2">&quot;isreference&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="n">row</span><span class="p">[</span><span class="mi">40</span><span class="p">]</span> <span class="o">==</span> <span class="mi">10</span>
<span class="k">try</span><span class="p">:</span>
<span class="n">info</span><span class="p">[</span><span class="s2">&quot;added_date&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="n">datetime</span><span class="o">.</span><span class="n">fromtimestamp</span><span class="p">(</span><span class="n">row</span><span class="p">[</span><span class="mi">41</span><span class="p">]</span> <span class="o">+</span> <span class="n">TIME_DELTA</span><span class="p">)</span>
<span class="k">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">TypeError</span><span class="p">):</span>
<span class="n">info</span><span class="p">[</span><span class="s2">&quot;added_date&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">(</span><span class="mi">1970</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
<span class="c1"># initialize import session info which will be filled in later</span>
<span class="c1"># not every photo has an import session so initialize all records now</span>
<span class="n">info</span><span class="p">[</span><span class="s2">&quot;import_session&quot;</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>

View File

@@ -1,6 +1,6 @@
var DOCUMENTATION_OPTIONS = {
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
VERSION: '0.42.14',
VERSION: '0.42.20',
LANGUAGE: 'None',
COLLAPSE_INDEX: false,
BUILDER: 'html',

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos command line interface (CLI) &#8212; osxphotos 0.42.14 documentation</title>
<title>osxphotos command line interface (CLI) &#8212; 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>
@@ -1455,6 +1455,12 @@ if more than one option is provided, they are treated as “AND”
<dd><p>Search for photos that are not in iCloud (have not been synched)</p>
</dd></dl>
<dl class="std option">
<dt id="cmdoption-osxphotos-query-add-to-album">
<code class="sig-name descname"><span class="pre">--add-to-album</span></code><code class="sig-prename descclassname"> <span class="pre">&lt;ALBUM&gt;</span></code><a class="headerlink" href="#cmdoption-osxphotos-query-add-to-album" title="Permalink to this definition"></a></dt>
<dd><p>Add all photos from query to album ALBUM in Photos. Album ALBUM will be created if it doesnt exist. All photos in the query results will be added to this album. This only works if the Photos library being queried is the last-opened (default) library in Photos. This feature is currently experimental. I dont know how well it will work on large query sets.</p>
</dd></dl>
<p class="rubric">Arguments</p>
<dl class="std option">
<dt id="cmdoption-osxphotos-query-arg-PHOTOS_LIBRARY">

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Index &#8212; osxphotos 0.42.14 documentation</title>
<title>Index &#8212; 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>
@@ -83,6 +83,13 @@
<ul>
<li><a href="cli.html#cmdoption-osxphotos-export-add-skipped-to-album">osxphotos-export command line option</a>
</li>
</ul></li>
<li>
--add-to-album &lt;ALBUM&gt;
<ul>
<li><a href="cli.html#cmdoption-osxphotos-query-add-to-album">osxphotos-query command line option</a>
</li>
</ul></li>
<li>
@@ -1210,6 +1217,8 @@
<table style="width: 100%" class="indextable genindextable"><tr>
<td style="width: 33%; vertical-align: top;"><ul>
<li><a href="reference.html#osxphotos.PhotoInfo.date">date() (osxphotos.PhotoInfo property)</a>
</li>
<li><a href="reference.html#osxphotos.PhotoInfo.date_added">date_added() (osxphotos.PhotoInfo property)</a>
</li>
<li><a href="reference.html#osxphotos.PhotoInfo.date_modified">date_modified() (osxphotos.PhotoInfo property)</a>
</li>
@@ -1825,6 +1834,8 @@
osxphotos-query command line option
<ul>
<li><a href="cli.html#cmdoption-osxphotos-query-add-to-album">--add-to-album &lt;ALBUM&gt;</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-query-album">--album &lt;ALBUM&gt;</a>
</li>
<li><a href="cli.html#cmdoption-osxphotos-query-burst">--burst</a>
@@ -1981,6 +1992,8 @@
<li><a href="reference.html#osxphotos.PhotoInfo.panorama">panorama() (osxphotos.PhotoInfo property)</a>
</li>
<li><a href="reference.html#osxphotos.PhotoInfo.path">path() (osxphotos.PhotoInfo property)</a>
</li>
<li><a href="reference.html#osxphotos.PhotoInfo.path_derivatives">path_derivatives() (osxphotos.PhotoInfo property)</a>
</li>
<li><a href="reference.html#osxphotos.PhotoInfo.path_edited">path_edited() (osxphotos.PhotoInfo property)</a>
</li>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to osxphotoss documentation! &#8212; osxphotos 0.42.14 documentation</title>
<title>Welcome to osxphotoss documentation! &#8212; 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>
@@ -150,6 +150,10 @@ Alternatively, you can also run the command line utility like this: <code class=
<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">&quot;{place.name.country,NoCountry}/{created.year}&quot;</span>&#160; <span class="pre">--person-keyword</span> <span class="pre">--album-keyword</span> <span class="pre">--keyword-template</span> <span class="pre">&quot;{created.year}&quot;</span> <span class="pre">--exiftool</span> <span class="pre">--update</span> <span class="pre">--verbose</span></code></p>
</div>
<div class="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">&quot;Big</span> <span class="pre">Videos&quot;</span></code></p>
</div>
</div>
</div>
<div class="section" id="example-uses-of-the-package">

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos &#8212; osxphotos 0.42.14 documentation</title>
<title>osxphotos &#8212; 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>

Binary file not shown.

Binary file not shown.

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>osxphotos package &#8212; osxphotos 0.42.14 documentation</title>
<title>osxphotos package &#8212; 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>
@@ -732,6 +732,12 @@ self is not included in the returned list</p>
<dd><p>image creation date as timezone aware datetime object</p>
</dd></dl>
<dl class="py method">
<dt id="osxphotos.PhotoInfo.date_added">
<em class="property"><span class="pre">property</span> </em><code class="sig-name descname"><span class="pre">date_added</span></code><a class="headerlink" href="#osxphotos.PhotoInfo.date_added" title="Permalink to this definition"></a></dt>
<dd><p>Date photo was added to the database</p>
</dd></dl>
<dl class="py method">
<dt id="osxphotos.PhotoInfo.date_modified">
<em class="property"><span class="pre">property</span> </em><code class="sig-name descname"><span class="pre">date_modified</span></code><a class="headerlink" href="#osxphotos.PhotoInfo.date_modified" title="Permalink to this definition"></a></dt>
@@ -1130,6 +1136,12 @@ Photos 5 mangles filenames upon import</p>
<dd><p>absolute path on disk of the original picture</p>
</dd></dl>
<dl class="py method">
<dt id="osxphotos.PhotoInfo.path_derivatives">
<em class="property"><span class="pre">property</span> </em><code class="sig-name descname"><span class="pre">path_derivatives</span></code><a class="headerlink" href="#osxphotos.PhotoInfo.path_derivatives" title="Permalink to this definition"></a></dt>
<dd><p>Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)</p>
</dd></dl>
<dl class="py method">
<dt id="osxphotos.PhotoInfo.path_edited">
<em class="property"><span class="pre">property</span> </em><code class="sig-name descname"><span class="pre">path_edited</span></code><a class="headerlink" href="#osxphotos.PhotoInfo.path_edited" title="Permalink to this definition"></a></dt>

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search &#8212; osxphotos 0.42.14 documentation</title>
<title>Search &#8212; 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" />

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Export your photos &#8212; osxphotos 0.42.14 documentation</title>
<title>Export your photos &#8212; 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>

View File

@@ -315,6 +315,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!):

View File

@@ -1,5 +1,6 @@
from ._version import __version__
from .photoinfo import PhotoInfo
from .exiftool import ExifTool
from .photoinfo import ExportResults, PhotoInfo
from .photosdb import PhotosDB
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
from .phototemplate import PhotoTemplate
@@ -7,5 +8,4 @@ from .queryoptions import QueryOptions
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

View File

@@ -70,6 +70,7 @@ _TESTED_OS_VERSIONS = [
("11", "0"),
("11", "1"),
("11", "2"),
("11", "3"),
]
# Photos 5 has persons who are empty string if unidentified face
@@ -212,8 +213,31 @@ 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_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",
}

View File

@@ -1,3 +1,3 @@
""" version info """
__version__ = "0.42.14"
__version__ = "0.42.37"

View File

@@ -7,14 +7,14 @@ import os
import os.path
import pathlib
import pprint
import shlex
import subprocess
import sys
import time
import unicodedata
import bitmath
import click
import osxmetadata
import photoscript
import yaml
import osxphotos
@@ -33,10 +33,10 @@ from ._constants import (
EXTENDED_ATTRIBUTE_NAMES_QUOTED,
OSXPHOTOS_EXPORT_DB,
OSXPHOTOS_URL,
POST_COMMAND_CATEGORIES,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
UNICODE_FORMAT,
)
from ._version import __version__
from .cli_help import ExportCommand
@@ -52,9 +52,10 @@ from .fileutil import FileUtil, FileUtilNoOp
from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults
from .photokit import check_photokit_authorization, request_photokit_authorization
from .photosalbum import PhotosAlbum
from .phototemplate import PhotoTemplate, RenderOptions
from .queryoptions import QueryOptions
from .utils import get_preferred_uti_extension
from .photosalbum import PhotosAlbum
# global variable to control verbose output
# set via --verbose/-V
@@ -62,7 +63,7 @@ VERBOSE = False
def verbose_(*args, **kwargs):
""" print output if verbose flag set """
"""print output if verbose flag set"""
if VERBOSE:
styled_args = []
for arg in args:
@@ -458,6 +459,14 @@ def QUERY_OPTIONS(f):
is_flag=True,
help="Search for photos that are not in any albums.",
),
o(
"--duplicate",
is_flag=True,
help="Search for photos with possible duplicates. osxphotos will compare signatures of photos, "
"evaluating date created, size, height, width, and edited status to find *possible* duplicates. "
"This does not compare images byte-for-byte nor compare hashes but should find photos imported multiple "
"times or duplicated within Photos.",
),
o(
"--min-size",
metavar="SIZE",
@@ -904,6 +913,19 @@ def cli(ctx, db, json_, debug):
"This only works if the Photos library being exported is the last-opened (default) library in Photos. "
"This feature is currently experimental. I don't know how well it will work on large export sets.",
)
@click.option(
"--post-command",
metavar="CATEGORY COMMAND",
nargs=2,
type=(click.Choice(POST_COMMAND_CATEGORIES, case_sensitive=False), str),
multiple=True,
help="Run COMMAND on exported files of category CATEGORY. CATEGORY can be one of: "
f"{', '.join(list(POST_COMMAND_CATEGORIES.keys()))}. "
"COMMAND is an osxphotos template string, for example: '--post-command exported \"echo {filepath|shell_quote} >> {export_dir}/exported.txt\"', "
"which appends the full path of all exported files to the file 'exported.txt'. "
"You can run more than one command by repeating the '--post-command' option with different arguments. "
"See Post Command below.",
)
@click.option(
"--exportdb",
metavar="EXPORTDB_FILE",
@@ -1067,6 +1089,8 @@ def export(
max_size,
regex,
query_eval,
duplicate,
post_command,
):
"""Export photos from the Photos database.
Export path DEST is required.
@@ -1221,6 +1245,8 @@ def export(
max_size = cfg.max_size
regex = cfg.regex
query_eval = cfg.query_eval
duplicate = cfg.duplicate
post_command = cfg.post_command
# config file might have changed verbose
VERBOSE = bool(verbose)
@@ -1526,6 +1552,7 @@ def export(
max_size=max_size,
regex=regex,
query_eval=query_eval,
duplicate=duplicate,
)
try:
@@ -1539,12 +1566,12 @@ def export(
else:
raise ValueError(e)
if photos:
if only_new:
# ignore previously exported files
previous_uuids = {uuid: 1 for uuid in export_db.get_previous_uuids()}
photos = [p for p in photos if p.uuid not in previous_uuids]
if photos and only_new:
# ignore previously exported files
previous_uuids = {uuid: 1 for uuid in export_db.get_previous_uuids()}
photos = [p for p in photos if p.uuid not in previous_uuids]
if photos:
num_photos = len(photos)
# TODO: photos or photo appears several times, pull into a separate function
photo_str = "photos" if num_photos > 1 else "photo"
@@ -1620,6 +1647,16 @@ def export(
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
retry=retry,
export_dir=dest,
)
run_post_command(
photo=p,
post_command=post_command,
export_results=export_results,
export_dir=dest,
dry_run=dry_run,
exiftool_path=exiftool_path,
)
if album_export and export_results.exported:
@@ -1630,8 +1667,9 @@ def export(
for filename in export_results.exported
]
except Exception as e:
click.echo(
click.secho(
f"Error adding photo {p.original_filename} ({p.uuid}) to album {album_export.name}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
@@ -1643,8 +1681,9 @@ def export(
for filename in export_results.skipped
]
except Exception as e:
click.echo(
click.secho(
f"Error adding photo {p.original_filename} ({p.uuid}) to album {album_skipped.name}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
@@ -1656,8 +1695,9 @@ def export(
for filename in export_results.missing
]
except Exception as e:
click.echo(
click.secho(
f"Error adding photo {p.original_filename} ({p.uuid}) to album {album_missing.name}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
@@ -1685,13 +1725,18 @@ def export(
exiftool_merge_keywords=exiftool_merge_keywords,
finder_tag_template=finder_tag_template,
strip=strip,
export_dir=dest,
)
results.xattr_written.extend(tags_written)
results.xattr_skipped.extend(tags_skipped)
if xattr_template:
xattr_written, xattr_skipped = write_extended_attributes(
p, photo_files, xattr_template, strip=strip
p,
photo_files,
xattr_template,
strip=strip,
export_dir=dest,
)
results.xattr_written.extend(xattr_written)
results.xattr_skipped.extend(xattr_skipped)
@@ -1765,7 +1810,7 @@ def export(
@click.argument("topic", default=None, required=False, nargs=1)
@click.pass_context
def help(ctx, topic, **kw):
""" Print help; for help on commands: help <command>. """
"""Print help; for help on commands: help <command>."""
if topic is None:
click.echo(ctx.parent.get_help())
elif topic in cli.commands:
@@ -1807,6 +1852,14 @@ def help(ctx, topic, **kw):
is_flag=True,
help="Search for photos that are not in iCloud (have not been synched)",
)
@click.option(
"--add-to-album",
metavar="ALBUM",
help="Add all photos from query to album ALBUM in Photos. Album ALBUM will be created "
"if it doesn't exist. All photos in the query results will be added to this album. "
"This only works if the Photos library being queried is the last-opened (default) library in Photos. "
"This feature is currently experimental. I don't know how well it will work on large query sets.",
)
@DB_ARGUMENT
@click.pass_obj
@click.pass_context
@@ -1880,10 +1933,12 @@ def query(
is_reference,
in_album,
not_in_album,
duplicate,
min_size,
max_size,
regex,
query_eval,
add_to_album,
):
"""Query the Photos database using 1 or more search options;
if more than one option is provided, they are treated as "AND"
@@ -1914,6 +1969,7 @@ def query(
min_size,
max_size,
regex,
duplicate,
]
exclusive = [
(favorite, not_favorite),
@@ -2039,6 +2095,7 @@ def query(
max_size=max_size,
query_eval=query_eval,
regex=regex,
duplicate=duplicate,
)
try:
@@ -2054,6 +2111,24 @@ def query(
# below needed for to make CliRunner work for testing
cli_json = cli_obj.json if cli_obj is not None else None
if add_to_album and photos:
album_query = PhotosAlbum(add_to_album, verbose=None)
photo_len = len(photos)
photo_word = "photos" if photo_len > 1 else "photo"
click.echo(
f"Adding {photo_len} {photo_word} to album '{album_query.name}'. Note: Photos may prompt you to confirm this action.",
err=True,
)
try:
album_query.add_list(photos)
except Exception as e:
click.secho(
f"Error adding photos to album {add_to_album}: {e}",
fg=CLI_COLOR_ERROR,
err=True,
)
print_photo_info(photos, cli_json or json_)
@@ -2203,6 +2278,7 @@ def export_photo(
jpeg_ext=None,
replace_keywords=False,
retry=0,
export_dir=None,
):
"""Helper function for export that does the actual export
@@ -2243,6 +2319,7 @@ def export_photo(
jpeg_ext: if not None, specify the extension to use for all JPEG images on export
replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords
retry: retry up to retry # of times if there's an error
export_dir: top-level export directory for {export_dir} template
Returns:
list of path(s) of exported photo or None if photo was missing
@@ -2306,9 +2383,8 @@ def export_photo(
rendered_suffix = ""
if original_suffix:
try:
rendered_suffix, unmatched = photo.render_template(
original_suffix, filename=True, strip=strip
)
options = RenderOptions(filename=True, strip=strip, export_dir=dest)
rendered_suffix, unmatched = photo.render_template(original_suffix, options)
except ValueError as e:
raise click.BadOptionUsage(
"original_suffix",
@@ -2402,6 +2478,7 @@ def export_photo(
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
retry=retry,
export_dir=export_dir,
)
if export_edited and photo.hasadjustments:
@@ -2435,8 +2512,13 @@ def export_photo(
if edited_suffix:
try:
options = RenderOptions(
filename=True,
strip=strip,
export_dir=dest,
)
rendered_suffix, unmatched = photo.render_template(
edited_suffix, filename=True, strip=strip
edited_suffix, options
)
except ValueError as e:
raise click.BadOptionUsage(
@@ -2501,6 +2583,7 @@ def export_photo(
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
retry=retry,
export_dir=export_dir,
)
return results
@@ -2545,8 +2628,9 @@ def export_photo_with_template(
jpeg_ext,
replace_keywords,
retry,
export_dir,
):
""" Evaluate directory template then export photo to each directory """
"""Evaluate directory template then export photo to each directory"""
results = ExportResults()
@@ -2595,6 +2679,8 @@ def export_photo_with_template(
results.missing.append(str(pathlib.Path(dest_path) / filename))
continue
render_options = RenderOptions(export_dir=export_dir)
tries = 0
while tries <= retry:
tries += 1
@@ -2632,6 +2718,7 @@ def export_photo_with_template(
exiftool_flags=exiftool_option,
jpeg_ext=jpeg_ext,
replace_keywords=replace_keywords,
render_options=render_options,
)
for warning_ in export_results.exiftool_warning:
verbose_(f"exiftool warning for file {warning_[0]}: {warning_[1]}")
@@ -2693,7 +2780,11 @@ def export_photo_with_template(
def get_filenames_from_template(
photo, filename_template, original_name, strip=False, edited=False
photo,
filename_template,
original_name,
strip=False,
edited=False,
):
"""get list of export filenames for a photo
@@ -2713,13 +2804,13 @@ def get_filenames_from_template(
if filename_template:
photo_ext = pathlib.Path(photo.original_filename).suffix
try:
filenames, unmatched = photo.render_template(
filename_template,
options = RenderOptions(
path_sep="_",
filename=True,
strip=strip,
edited=edited,
edited_version=edited,
)
filenames, unmatched = photo.render_template(filename_template, options)
except ValueError as e:
raise click.BadOptionUsage(
"filename_template", f"Invalid template '{filename_template}': {e}"
@@ -2773,9 +2864,8 @@ def get_dirnames_from_template(
elif directory:
# got a directory template, render it and check results are valid
try:
dirnames, unmatched = photo.render_template(
directory, dirname=True, strip=strip, edited=edited
)
options = RenderOptions(dirname=True, strip=strip, edited_version=edited)
dirnames, unmatched = photo.render_template(directory, options)
except ValueError as e:
raise click.BadOptionUsage(
"directory", f"Invalid template '{directory}': {e}"
@@ -3056,6 +3146,7 @@ def write_finder_tags(
exiftool_merge_keywords=None,
finder_tag_template=None,
strip=False,
export_dir=None,
):
"""Write Finder tags (extended attributes) to files; only writes attributes if attributes on file differ from what would be written
@@ -3068,6 +3159,7 @@ def write_finder_tags(
person_keyword: if True, use person in image as keywords
exiftool_merge_keywords: if True, include any keywords in the exif data of the source image as keywords
finder_tag_template: list of templates to evaluate for determining Finder tags
export_dir: value to use for {export_dir} template
Returns:
(list of file paths that were updated with new Finder tags, list of file paths skipped because Finder tags didn't need updating)
@@ -3094,12 +3186,13 @@ def write_finder_tags(
rendered_tags = []
for template_str in finder_tag_template:
try:
rendered, unmatched = photo.render_template(
template_str,
options = RenderOptions(
none_str=_OSXPHOTOS_NONE_SENTINEL,
path_sep="/",
strip=strip,
export_dir=export_dir,
)
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e:
raise click.BadOptionUsage(
"finder_tag_template",
@@ -3136,13 +3229,16 @@ def write_finder_tags(
return (written, skipped)
def write_extended_attributes(photo, files, xattr_template, strip=False):
""" Writes extended attributes to exported files
def write_extended_attributes(
photo, files, xattr_template, strip=False, export_dir=None
):
"""Writes extended attributes to exported files
Args:
photo: a PhotoInfo object
xattr_template: list of tuples: (attribute name, attribute template)
strip: xattr_template: list of tuples: (attribute name, attribute template)
export_dir: value to use for {export_dir} template
Returns:
tuple(list of file paths that were updated with new attributes, list of file paths skipped because attributes didn't need updating)
"""
@@ -3150,12 +3246,13 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
attributes = {}
for xattr, template_str in xattr_template:
try:
rendered, unmatched = photo.render_template(
template_str,
options = RenderOptions(
none_str=_OSXPHOTOS_NONE_SENTINEL,
path_sep="/",
strip=strip,
export_dir=export_dir,
)
rendered, unmatched = photo.render_template(template_str, options)
except ValueError as e:
raise click.BadOptionUsage(
"xattr_template",
@@ -3204,6 +3301,46 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
return list(written), [f for f in skipped if f not in written]
def run_post_command(
photo, post_command, export_results, export_dir, dry_run, exiftool_path
):
# todo: pass in RenderOptions from export? (e.g. so it contains strip, etc?)
# todo: need a shell_quote template type:
# {shell_quote,{filepath}/foo/bar}
# that quotes everything in the default value
for category, command_template in post_command:
files = getattr(export_results, category)
for f in files:
# some categories, like error, return a tuple of (file, error str)
if isinstance(f, tuple):
f = f[0]
render_options = RenderOptions(export_dir=export_dir, filepath=f)
template = PhotoTemplate(photo, exiftool_path=exiftool_path)
command, _ = template.render(command_template, options=render_options)
command = command[0] if command else None
if command:
verbose_(f'Running command: "{command}"')
if not dry_run:
args = shlex.split(command)
cwd = pathlib.Path(f).parent
run_error = None
run_results = None
try:
run_results = subprocess.run(command, shell=True, cwd=cwd)
except Exception as e:
run_error = e
finally:
run_error = run_error or run_results.returncode
if run_error:
click.echo(
click.style(
f'Error running command "{command}": {run_error}',
fg=CLI_COLOR_ERROR,
),
err=True,
)
@cli.command(hidden=True)
@DB_OPTION
@DB_ARGUMENT
@@ -3224,7 +3361,7 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
@click.pass_obj
@click.pass_context
def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
""" Print out debug info """
"""Print out debug info"""
global VERBOSE
VERBOSE = bool(verbose)
@@ -3295,7 +3432,7 @@ def debug_dump(ctx, cli_obj, db, photos_library, dump, uuid, verbose):
@click.pass_obj
@click.pass_context
def keywords(ctx, cli_obj, db, json_, photos_library):
""" Print out keywords found in the Photos library. """
"""Print out keywords found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3321,7 +3458,7 @@ def keywords(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def albums(ctx, cli_obj, db, json_, photos_library):
""" Print out albums found in the Photos library. """
"""Print out albums found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3350,7 +3487,7 @@ def albums(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def persons(ctx, cli_obj, db, json_, photos_library):
""" Print out persons (faces) found in the Photos library. """
"""Print out persons (faces) found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3376,7 +3513,7 @@ def persons(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def labels(ctx, cli_obj, db, json_, photos_library):
""" Print out image classification labels found in the Photos library. """
"""Print out image classification labels found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3402,7 +3539,7 @@ def labels(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def info(ctx, cli_obj, db, json_, photos_library):
""" Print out descriptive info of the Photos library database. """
"""Print out descriptive info of the Photos library database."""
db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None:
@@ -3462,7 +3599,7 @@ def info(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def places(ctx, cli_obj, db, json_, photos_library):
""" Print out places found in the Photos library. """
"""Print out places found in the Photos library."""
# below needed for to make CliRunner work for testing
cli_db = cli_obj.db if cli_obj is not None else None
@@ -3513,7 +3650,7 @@ def places(ctx, cli_obj, db, json_, photos_library):
@click.pass_obj
@click.pass_context
def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
""" Print list of all photos & associated info from the Photos library. """
"""Print list of all photos & associated info from the Photos library."""
db = get_photos_db(*photos_library, db, cli_obj.db)
if db is None:
@@ -3544,7 +3681,7 @@ def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library):
@click.pass_obj
@click.pass_context
def list_libraries(ctx, cli_obj, json_):
""" Print list of Photos libraries found on the system. """
"""Print list of Photos libraries found on the system."""
# implemented in _list_libraries so it can be called by other CLI functions
# without errors due to passing ctx and cli_obj
@@ -3591,7 +3728,7 @@ def _list_libraries(json_=False, error=True):
@click.pass_obj
@click.pass_context
def about(ctx, cli_obj):
""" Print information about osxphotos including license. """
"""Print information about osxphotos including license."""
license = """
MIT License

View File

@@ -12,16 +12,19 @@ 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,
)
# 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 +68,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 +104,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 +135,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,12 +162,92 @@ 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 = [("Catgory", "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.name|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.name}' is properly quoted "
+ "and the '{shell_quote}' template ensures the constructed path of '{exported_dir}/exported.txt' is properly quoted. "
"If '{filepath.name}' 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")
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())
@@ -178,14 +269,14 @@ def rich_text(text, width=78):
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
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"(?:[*#])|\[(.*?)\]\(.+?\)"
@@ -194,4 +285,3 @@ def strip_md_links(md):
return match.group(1)
return re.sub(links, subfn, md)

View File

@@ -6,19 +6,30 @@
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 json
import logging
import os
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
# 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 = []
@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():
@@ -67,7 +78,8 @@ class _ExifToolProc:
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):
@@ -105,30 +117,30 @@ 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 """
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 """
@@ -154,9 +166,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
@@ -186,7 +201,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
@@ -228,7 +243,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.

View File

@@ -121,6 +121,6 @@ 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}"
)

View File

@@ -6,22 +6,22 @@ from ._constants import MAX_DIRNAME_LEN, MAX_FILENAME_LEN
def sanitize_filepath(filepath):
""" sanitize a filepath """
"""sanitize a filepath"""
return pathvalidate.sanitize_filepath(filepath, platform="macos")
def is_valid_filepath(filepath):
""" returns True if a filepath is valid otherwise False """
"""returns True if a filepath is valid otherwise False"""
return pathvalidate.is_valid_filepath(filepath, platform="macos")
def sanitize_filename(filename, replacement=":"):
""" replace any illegal characters in a filename and truncate filename if needed
"""replace any illegal characters in a filename and truncate filename if needed
Args:
filename: str, filename to sanitze
replacement: str, value to replace any illegal characters with; default = ":"
Returns:
filename with any illegal characters replaced by replacement and truncated if necessary
"""
@@ -46,12 +46,12 @@ def sanitize_filename(filename, replacement=":"):
def sanitize_dirname(dirname, replacement=":"):
""" replace any illegal characters in a directory name and truncate directory name if needed
"""replace any illegal characters in a directory name and truncate directory name if needed
Args:
dirname: str, directory name to sanitze
replacement: str, value to replace any illegal characters with; default = ":"
dirname: str, directory name to sanitize
replacement: str, value to replace any illegal characters with; default = ":"; if None, no replacement occurs
Returns:
dirname with any illegal characters replaced by replacement and truncated if necessary
"""
@@ -61,19 +61,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

View File

@@ -7,4 +7,4 @@ 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
from .photoinfo import PhotoInfo, PhotoInfoNone

View File

@@ -3,13 +3,13 @@
import logging
import os
from ..exiftool import ExifTool, get_exiftool_path
from ..exiftool import ExifToolCaching, get_exiftool_path
@property
def exiftool(self):
""" Returns an ExifTool object for the photo
requires that exiftool (https://exiftool.org/) be installed
""" Returns a ExifToolCaching (read-only instance of 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
"""
@@ -20,7 +20,7 @@ def exiftool(self):
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)
exiftool = ExifToolCaching(self.path, exiftool=exiftool_path)
else:
exiftool = None
except FileNotFoundError:

View File

@@ -15,6 +15,7 @@
# TODO: should this be its own PhotoExporter class?
# TODO: the various sidecar_json, sidecar_xmp, etc args should all be collapsed to a sidecar param using a bit mask
import dataclasses
import glob
import hashlib
import json
@@ -24,6 +25,7 @@ import pathlib
import re
import tempfile
from collections import namedtuple # pylint: disable=syntax-error
from typing import Optional
import photoscript
from mako.template import Template
@@ -36,6 +38,7 @@ from .._constants import (
_UNKNOWN_PERSON,
_XMP_TEMPLATE_NAME,
_XMP_TEMPLATE_NAME_BETA,
LIVE_VIDEO_EXTENSIONS,
SIDECAR_EXIFTOOL,
SIDECAR_JSON,
SIDECAR_XMP,
@@ -51,6 +54,7 @@ from ..photokit import (
PhotoKitFetchFailed,
PhotoLibrary,
)
from ..phototemplate import RenderOptions
from ..utils import findfiles, get_preferred_uti_extension, lineno, noop
# retry if use_photos_export fails the first time (which sometimes it does)
@@ -58,13 +62,13 @@ MAX_PHOTOSCRIPT_RETRIES = 3
class ExportError(Exception):
""" error during export """
"""error during export"""
pass
class ExportResults:
""" holds export results for export2 """
"""holds export results for export2"""
def __init__(
self,
@@ -119,7 +123,7 @@ class ExportResults:
self.missing_album = missing_album or []
def all_files(self):
""" return all filenames contained in results """
"""return all filenames contained in results"""
files = (
self.exported
+ self.new
@@ -200,7 +204,7 @@ class ExportResults:
# hexdigest is not a class method, don't import this into PhotoInfo
def hexdigest(strval):
""" hexdigest of a string, using blake2b """
"""hexdigest of a string, using blake2b"""
h = hashlib.blake2b(digest_size=20)
h.update(bytes(strval, "utf-8"))
return h.hexdigest()
@@ -343,13 +347,13 @@ def _check_export_suffix(src, dest, edited):
# not a class method, don't import into PhotoInfo
def rename_jpeg_files(files, jpeg_ext, fileutil):
""" rename any jpeg files in files so that extension matches jpeg_ext
"""rename any jpeg files in files so that extension matches jpeg_ext
Args:
files: list of file paths
jpeg_ext: extension to use for jpeg files found in files, e.g. "jpg"
fileutil: a FileUtil object
Returns:
list of files with updated names
@@ -389,6 +393,7 @@ def export(
use_persons_as_keywords=False,
keyword_template=None,
description_template=None,
render_options: Optional[RenderOptions] = None,
):
"""export photo
dest: must be valid destination path (or exception raised)
@@ -415,7 +420,7 @@ def export(
sidecar_exiftool: if set will write a json sidecar with data in format readable by exiftool
sidecar filename will be dest/filename.json; does not include exiftool tag group names (e.g. `exiftool -j`)
sidecar_xmp: if set will write an XMP sidecar with IPTC data
sidecar filename will be dest/filename.xmp
sidecar filename will be dest/filename.xmp
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export
exiftool: (boolean, default = False); if True, will use exiftool to write metadata to export file
@@ -426,7 +431,8 @@ def export(
when exporting metadata with exiftool or sidecar
keyword_template: (list of strings); list of template strings that will be rendered as used as keywords
description_template: string; optional template string that will be rendered for use as photo description
render_options: an optional osxphotos.phototemplate.RenderOptions instance with options to pass to template renderer
Returns: list of photos exported
"""
@@ -457,6 +463,7 @@ def export(
use_persons_as_keywords=use_persons_as_keywords,
keyword_template=keyword_template,
description_template=description_template,
render_options = render_options,
)
return results.exported
@@ -499,6 +506,7 @@ def export2(
persons=True,
location=True,
replace_keywords=False,
render_options: Optional[RenderOptions] = None
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -512,10 +520,10 @@ def export2(
reference PhotoInfo.path_edited
edited: (boolean, default=False); if True will export the edited version of the photo
(or raise exception if no edited version)
live_photo: (boolean, default=False); if True, will also export the associted .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associted RAW photo
live_photo: (boolean, default=False); if True, will also export the associated .mov for live photos
raw_photo: (boolean, default=False); if True, will also export the associated RAW photo
export_as_hardlink: (boolean, default=False); if True, will hardlink files instead of copying them
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
overwrite: (boolean, default=False); if True will overwrite files if they already exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists
sidecar: bit field: set to one or more of SIDECAR_XMP, SIDECAR_JSON, SIDECAR_EXIFTOOL
@@ -554,9 +562,10 @@ def export2(
persons: if True, include persons in exported metadata
location: if True, include location in exported metadata
replace_keywords: if True, keyword_template replaces any keywords, otherwise it's additive
render_options: optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
Returns: ExportResults class
ExportResults has attributes:
Returns: ExportResults class
ExportResults has attributes:
"exported",
"new",
"updated",
@@ -576,7 +585,7 @@ def export2(
"exiftool_warning",
"exiftool_error",
Note: to use dry run mode, you must set dry_run=True and also pass in memory version of export_db,
and no-op fileutil (e.g. ExportDBInMemory and FileUtilNoOp)
"""
@@ -595,6 +604,8 @@ def export2(
if verbose is None:
verbose = self._verbose
self._render_options = render_options or RenderOptions()
# suffix to add to edited files
# e.g. name will be filename_edited.jpg
edited_identifier = "_edited"
@@ -678,6 +689,7 @@ def export2(
f"destination exists ({dest}); overwrite={overwrite}, increment={increment}"
)
self._render_options.filepath = str(dest)
all_results = ExportResults()
if not use_photos_export:
# find the source file on disk and export
@@ -817,139 +829,25 @@ def export2(
)
all_results += results
else:
# TODO: move this big if/else block to separate functions
# e.g. _export_with_photos_export or such
# use_photo_export
# export live_photo .mov file?
live_photo = True if live_photo and self.live_photo else False
if edited or self.shared:
# exported edited version and not original
# shared photos (in shared albums) show up as not having adjustments (not edited)
# but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
# so tell Photos to export the current version in this case
if filename:
# use filename stem provided
filestem = dest.stem
else:
# didn't get passed a filename, add _edited
filestem = f"{dest.stem}{edited_identifier}"
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
dest = dest.parent / f"{filestem}{ext}"
if use_photokit:
photolib = PhotoLibrary()
photo = None
try:
photo = photolib.fetch_uuid(self.uuid)
except PhotoKitFetchFailed as e:
# if failed to find UUID, might be a burst photo
if self.burst and self._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
self._info["burstUUID"], all=True
)
# PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if not photo:
all_results.error.append(
(
str(dest),
f"PhotoKitFetchFailed exception exporting photo {self.uuid}: {e} ({lineno(__file__)})",
)
)
if photo:
if not dry_run:
try:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
)
all_results.exported.extend(exported)
except Exception as e:
all_results.error.append(
(str(dest), f"{e} ({lineno(__file__)})")
)
else:
# dry_run, don't actually export
all_results.exported.append(str(dest))
else:
try:
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=False,
edited=True,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
all_results.exported.extend(exported)
except ExportError as e:
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
else:
# export original version and not edited
filestem = dest.stem
if use_photokit:
photolib = PhotoLibrary()
photo = None
try:
photo = photolib.fetch_uuid(self.uuid)
except PhotoKitFetchFailed:
# if failed to find UUID, might be a burst photo
if self.burst and self._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
self._info["burstUUID"], all=True
)
# PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if photo:
if not dry_run:
try:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
)
all_results.exported.extend(exported)
except Exception as e:
all_results.error.append(
(str(dest), f"{e} ({lineno(__file__)})")
)
else:
# dry_run, don't actually export
all_results.exported.append(str(dest))
else:
try:
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=True,
edited=False,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
all_results.exported.extend(exported)
except ExportError as e:
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
if all_results.exported:
if jpeg_ext:
# use_photos_export (both PhotoKit and AppleScript) don't use the
# file extension provided (instead they use extension for UTI)
# so if jpeg_ext is set, rename any non-conforming jpegs
all_results.exported = rename_jpeg_files(
all_results.exported, jpeg_ext, fileutil
)
if touch_file:
for exported_file in all_results.exported:
all_results.touched.append(exported_file)
ts = int(self.date.timestamp())
fileutil.utime(exported_file, (ts, ts))
if update:
all_results.new.extend(all_results.exported)
self._export_photo_with_photos_export(
dest,
filename,
all_results,
fileutil,
export_db,
use_photokit=use_photokit,
dry_run=dry_run,
timeout=timeout,
jpeg_ext=jpeg_ext,
touch_file=touch_file,
update=update,
overwrite=overwrite,
live_photo=live_photo,
edited=edited,
edited_identifier=edited_identifier,
convert_to_jpeg=convert_to_jpeg,
jpeg_quality=jpeg_quality,
)
# export metadata
sidecars = []
@@ -1207,6 +1105,195 @@ def export2(
return all_results
def _export_photo_with_photos_export(
self,
dest,
filename,
all_results,
fileutil,
export_db,
use_photokit=None,
dry_run=None,
timeout=None,
jpeg_ext=None,
touch_file=None,
update=None,
overwrite=None,
live_photo=None,
edited=None,
edited_identifier=None,
convert_to_jpeg=None,
jpeg_quality=1.0,
):
# TODO: duplicative code with the if edited/else--remove it
# export live_photo .mov file?
live_photo = bool(live_photo and self.live_photo)
overwrite = overwrite or update
if edited or self.shared:
# exported edited version and not original
# shared photos (in shared albums) show up as not having adjustments (not edited)
# but Photos is unable to export the "original" as only a jpeg copy is shared in iCloud
# so tell Photos to export the current version in this case
if filename:
# use filename stem provided
filestem = dest.stem
else:
# didn't get passed a filename, add _edited
filestem = f"{dest.stem}{edited_identifier}"
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
dest = dest.parent / f"{filestem}{ext}"
if use_photokit:
photolib = PhotoLibrary()
photo = None
try:
photo = photolib.fetch_uuid(self.uuid)
except PhotoKitFetchFailed as e:
# if failed to find UUID, might be a burst photo
if self.burst and self._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
self._info["burstUUID"], all=True
)
# PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if not photo:
all_results.error.append(
(
str(dest),
f"PhotoKitFetchFailed exception exporting photo {self.uuid}: {e} ({lineno(__file__)})",
)
)
if photo:
if dry_run:
# dry_run, don't actually export
all_results.exported.append(str(dest))
else:
try:
exported = photo.export(
dest.parent,
dest.name,
version=PHOTOS_VERSION_CURRENT,
overwrite=overwrite,
)
all_results.exported.extend(exported)
except Exception as e:
all_results.error.append(
(str(dest), f"{e} ({lineno(__file__)})")
)
else:
try:
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=False,
edited=True,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
all_results.exported.extend(exported)
except ExportError as e:
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
else:
# export original version and not edited
filestem = dest.stem
if use_photokit:
photolib = PhotoLibrary()
photo = None
try:
photo = photolib.fetch_uuid(self.uuid)
except PhotoKitFetchFailed:
# if failed to find UUID, might be a burst photo
if self.burst and self._info["burstUUID"]:
bursts = photolib.fetch_burst_uuid(
self._info["burstUUID"], all=True
)
# PhotoKit UUIDs may contain "/L0/001" so only look at beginning
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if photo:
if not dry_run:
try:
exported = photo.export(
dest.parent,
dest.name,
version=PHOTOS_VERSION_ORIGINAL,
overwrite=overwrite,
)
all_results.exported.extend(exported)
except Exception as e:
all_results.error.append(
(str(dest), f"{e} ({lineno(__file__)})")
)
else:
# dry_run, don't actually export
all_results.exported.append(str(dest))
else:
try:
exported = _export_photo_uuid_applescript(
self.uuid,
dest.parent,
filestem=filestem,
original=True,
edited=False,
live_photo=live_photo,
timeout=timeout,
burst=self.burst,
dry_run=dry_run,
)
all_results.exported.extend(exported)
except ExportError as e:
all_results.error.append((str(dest), f"{e} ({lineno(__file__)})"))
if all_results.exported:
for idx, photopath in enumerate(all_results.exported):
converted_stat = (None, None, None)
photopath = pathlib.Path(photopath)
if convert_to_jpeg and self.isphoto:
# if passed convert_to_jpeg=True, will assume the photo is a photo and not already a jpeg
if photopath.suffix.lower() not in LIVE_VIDEO_EXTENSIONS:
dest_str = photopath.parent / f"{photopath.stem}.jpeg"
fileutil.convert_to_jpeg(
photopath, dest_str, compression_quality=jpeg_quality
)
converted_stat = fileutil.file_sig(dest_str)
fileutil.unlink(photopath)
all_results.exported[idx] = dest_str
all_results.converted_to_jpeg.append(dest_str)
photopath = dest_str
photopath = str(photopath)
export_db.set_data(
filename=photopath,
uuid=self.uuid,
orig_stat=fileutil.file_sig(photopath),
exif_stat=(None, None, None),
converted_stat=converted_stat,
edited_stat=(None, None, None),
info_json=self.json(),
exif_json=None,
)
# todo: handle signatures
if jpeg_ext:
# use_photos_export (both PhotoKit and AppleScript) don't use the
# file extension provided (instead they use extension for UTI)
# so if jpeg_ext is set, rename any non-conforming jpegs
all_results.exported = rename_jpeg_files(
all_results.exported, jpeg_ext, fileutil
)
if touch_file:
for exported_file in all_results.exported:
all_results.touched.append(exported_file)
ts = int(self.date.timestamp())
fileutil.utime(exported_file, (ts, ts))
if update:
all_results.new.extend(all_results.exported)
def _export_photo(
self,
src,
@@ -1501,7 +1588,7 @@ def _exiftool_dict(
QuickTime:GPSCoordinates
UserData:GPSCoordinates
Reference:
Reference:
https://iptc.org/std/photometadata/specification/IPTC-PhotoMetadata-201610_1.pdf
"""
@@ -1516,9 +1603,8 @@ def _exiftool_dict(
)
if description_template is not None:
rendered = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else ""
exif["EXIF:ImageDescription"] = description
exif["XMP:Description"] = description
@@ -1556,10 +1642,9 @@ def _exiftool_dict(
if keyword_template:
rendered_keywords = []
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
for template_str in keyword_template:
rendered, unmatched = self.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
rendered, unmatched = self.render_template(template_str, options)
if unmatched:
logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}"
@@ -1682,7 +1767,7 @@ def _exiftool_dict(
def _get_exif_keywords(self):
""" returns list of keywords found in the file's exif metadata """
"""returns list of keywords found in the file's exif metadata"""
keywords = []
exif = self.exiftool
if exif:
@@ -1700,7 +1785,7 @@ def _get_exif_keywords(self):
def _get_exif_persons(self):
""" returns list of persons found in the file's exif metadata """
"""returns list of persons found in the file's exif metadata"""
persons = []
exif = self.exiftool
if exif:
@@ -1835,9 +1920,8 @@ def _xmp_sidecar(
extension = extension.suffix[1:] if extension.suffix else None
if description_template is not None:
rendered = self.render_template(
description_template, expand_inplace=True, inplace_sep=", "
)[0]
options = dataclasses.replace(self._render_options, expand_inplace=True, inplace_sep=", ")
rendered = self.render_template(description_template, options)[0]
description = " ".join(rendered) if rendered else ""
else:
description = self.description if self.description is not None else ""
@@ -1869,10 +1953,9 @@ def _xmp_sidecar(
if keyword_template:
rendered_keywords = []
options = dataclasses.replace(self._render_options, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/")
for template_str in keyword_template:
rendered, unmatched = self.render_template(
template_str, none_str=_OSXPHOTOS_NONE_SENTINEL, path_sep="/"
)
rendered, unmatched = self.render_template(template_str, options)
if unmatched:
logging.warning(
f"Unmatched template substitution for template: {template_str} {unmatched}"

View File

@@ -3,7 +3,6 @@ PhotoInfo class
Represents a single photo in the Photos library and provides access to the photo's attributes
PhotosDB.photos() returns a list of PhotoInfo objects
"""
import dataclasses
import datetime
import json
@@ -12,6 +11,7 @@ import os
import os.path
import pathlib
from datetime import timedelta, timezone
from typing import Optional
import yaml
@@ -34,7 +34,7 @@ from .._constants import (
from ..adjustmentsinfo import AdjustmentsInfo
from ..albuminfo import AlbumInfo, ImportInfo
from ..personinfo import FaceInfo, PersonInfo
from ..phototemplate import PhotoTemplate
from ..phototemplate import PhotoTemplate, RenderOptions
from ..placeinfo import PlaceInfo4, PlaceInfo5
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
@@ -46,30 +46,31 @@ class PhotoInfo:
"""
# import additional methods
from ._photoinfo_searchinfo import (
search_info,
search_info_normalized,
labels,
labels_normalized,
SearchInfo,
)
from ._photoinfo_exifinfo import exif_info, ExifInfo
from ._photoinfo_comments import comments, likes
from ._photoinfo_exifinfo import ExifInfo, exif_info
from ._photoinfo_exiftool import exiftool
from ._photoinfo_export import (
export,
export2,
_export_photo,
ExportResults,
_exiftool_dict,
_exiftool_json_sidecar,
_export_photo,
_export_photo_with_photos_export,
_get_exif_keywords,
_get_exif_persons,
_write_exif_data,
_write_sidecar,
_xmp_sidecar,
ExportResults,
export,
export2,
)
from ._photoinfo_scoreinfo import ScoreInfo, score
from ._photoinfo_searchinfo import (
SearchInfo,
labels,
labels_normalized,
search_info,
search_info_normalized,
)
from ._photoinfo_scoreinfo import score, ScoreInfo
from ._photoinfo_comments import comments, likes
def __init__(self, db=None, uuid=None, info=None):
self._uuid = uuid
@@ -77,9 +78,12 @@ class PhotoInfo:
self._db = db
self._verbose = self._db._verbose
# TODO: remove this once refactor of PhotoExporter is done
self._render_options = RenderOptions()
@property
def filename(self):
""" filename of the picture """
"""filename of the picture"""
if (
self._db._db_version <= _PHOTOS_4_VERSION
and self.has_raw
@@ -107,7 +111,7 @@ class PhotoInfo:
@property
def date(self):
""" image creation date as timezone aware datetime object """
"""image creation date as timezone aware datetime object"""
return self._info["imageDate"]
@property
@@ -133,12 +137,12 @@ class PhotoInfo:
@property
def tzoffset(self):
""" timezone offset from UTC in seconds """
"""timezone offset from UTC in seconds"""
return self._info["imageTimeZoneOffsetSeconds"]
@property
def path(self):
""" absolute path on disk of the original picture """
"""absolute path on disk of the original picture"""
try:
return self._path
except AttributeError:
@@ -210,7 +214,7 @@ class PhotoInfo:
@property
def path_edited(self):
""" absolute path on disk of the edited picture """
"""absolute path on disk of the edited picture"""
""" None if photo has not been edited """
try:
@@ -224,7 +228,7 @@ class PhotoInfo:
return self._path_edited
def _path_edited_5(self):
""" return path_edited for Photos >= 5 """
"""return path_edited for Photos >= 5"""
# In Photos 5.0 / Catalina / MacOS 10.15:
# edited photos appear to always be converted to .jpeg and stored in
# library_name/resources/renders/X/UUID_1_201_a.jpeg
@@ -282,7 +286,7 @@ class PhotoInfo:
return photopath
def _path_edited_4(self):
""" return path_edited for Photos <= 4 """
"""return path_edited for Photos <= 4"""
if self._db._db_version > _PHOTOS_4_VERSION:
raise RuntimeError("Wrong database format!")
@@ -342,7 +346,7 @@ class PhotoInfo:
@property
def path_raw(self):
""" absolute path of associated RAW image or None if there is not one """
"""absolute path of associated RAW image or None if there is not one"""
# In Photos 5, raw is in same folder as original but with _4.ext
# Unless "Copy Items to the Photos Library" is not checked
@@ -412,17 +416,17 @@ class PhotoInfo:
@property
def description(self):
""" long / extended description of picture """
"""long / extended description of picture"""
return self._info["extendedDescription"]
@property
def persons(self):
""" list of persons in picture """
"""list of persons in picture"""
return [self._db._dbpersons_pk[pk]["fullname"] for pk in self._info["persons"]]
@property
def person_info(self):
""" list of PersonInfo objects for person in picture """
"""list of PersonInfo objects for person in picture"""
try:
return self._personinfo
except AttributeError:
@@ -433,7 +437,7 @@ class PhotoInfo:
@property
def face_info(self):
""" list of FaceInfo objects for faces in picture """
"""list of FaceInfo objects for faces in picture"""
try:
return self._faceinfo
except AttributeError:
@@ -447,7 +451,7 @@ class PhotoInfo:
@property
def albums(self):
""" list of albums picture is contained in """
"""list of albums picture is contained in"""
try:
return self._albums
except AttributeError:
@@ -459,7 +463,7 @@ class PhotoInfo:
@property
def burst_albums(self):
"""If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums """
"""If photo is burst photo, list of albums it is contained in as well as any albums the key photo is contained in, otherwise returns self.albums"""
try:
return self._burst_albums
except AttributeError:
@@ -472,7 +476,7 @@ class PhotoInfo:
@property
def album_info(self):
""" list of AlbumInfo objects representing albums the photo is contained in """
"""list of AlbumInfo objects representing albums the photo is contained in"""
try:
return self._album_info
except AttributeError:
@@ -484,11 +488,11 @@ class PhotoInfo:
@property
def burst_album_info(self):
""" If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info. """
"""If photo is a burst photo, returns list of AlbumInfo objects representing albums the photo is contained in as well as albums the burst key photo is contained in, otherwise returns self.album_info."""
try:
return self._burst_album_info
except AttributeError:
burst_album_info = list(self.album_info)
burst_album_info = list(self.album_info)
for photo in self.burst_photos:
if photo.burst_key:
burst_album_info.extend(photo.album_info)
@@ -497,7 +501,7 @@ class PhotoInfo:
@property
def import_info(self):
""" ImportInfo object representing import session for the photo or None if no import session """
"""ImportInfo object representing import session for the photo or None if no import session"""
try:
return self._import_info
except AttributeError:
@@ -510,17 +514,17 @@ class PhotoInfo:
@property
def keywords(self):
""" list of keywords for picture """
"""list of keywords for picture"""
return self._info["keywords"]
@property
def title(self):
""" name / title of picture """
"""name / title of picture"""
return self._info["name"]
@property
def uuid(self):
""" UUID of picture """
"""UUID of picture"""
return self._uuid
@property
@@ -538,12 +542,12 @@ class PhotoInfo:
@property
def hasadjustments(self):
""" True if picture has adjustments / edits """
"""True if picture has adjustments / edits"""
return self._info["hasAdjustments"] == 1
@property
def adjustments(self):
""" Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only """
"""Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return None
@@ -567,32 +571,32 @@ class PhotoInfo:
@property
def external_edit(self):
""" Returns True if picture was edited outside of Photos using external editor """
"""Returns True if picture was edited outside of Photos using external editor"""
return self._info["adjustmentFormatID"] == "com.apple.Photos.externalEdit"
@property
def favorite(self):
""" True if picture is marked as favorite """
"""True if picture is marked as favorite"""
return self._info["favorite"] == 1
@property
def hidden(self):
""" True if picture is hidden """
"""True if picture is hidden"""
return self._info["hidden"] == 1
@property
def visible(self):
""" True if picture is visble """
"""True if picture is visble"""
return self._info["visible"]
@property
def intrash(self):
""" True if picture is in trash ('Recently Deleted' folder)"""
"""True if picture is in trash ('Recently Deleted' folder)"""
return self._info["intrash"]
@property
def date_trashed(self):
""" Date asset was placed in the trash or None """
"""Date asset was placed in the trash or None"""
# TODO: add add_timezone(dt, offset_seconds) to datetime_utils
# also update date_modified
trasheddate = self._info["trasheddate"]
@@ -604,9 +608,26 @@ class PhotoInfo:
else:
return None
@property
def date_added(self):
"""Date photo was added to the database"""
try:
return self._date_added
except AttributeError:
added_date = self._info["added_date"]
if added_date:
seconds = self._info["imageTimeZoneOffsetSeconds"] or 0
delta = timedelta(seconds=seconds)
tz = timezone(delta)
self._date_added = added_date.astimezone(tz=tz)
else:
self._date_added = None
return self._date_added
@property
def location(self):
""" returns (latitude, longitude) as float in degrees or None """
"""returns (latitude, longitude) as float in degrees or None"""
return (self._latitude, self._longitude)
@property
@@ -702,27 +723,27 @@ class PhotoInfo:
@property
def isreference(self):
""" Returns True if photo is a reference (not copied to the Photos library), otherwise False """
"""Returns True if photo is a reference (not copied to the Photos library), otherwise False"""
return self._info["isreference"]
@property
def burst(self):
""" Returns True if photo is part of a Burst photo set, otherwise False """
"""Returns True if photo is part of a Burst photo set, otherwise False"""
return self._info["burst"]
@property
def burst_selected(self):
""" Returns True if photo is a burst photo and has been selected from the burst set by the user, otherwise False """
"""Returns True if photo is a burst photo and has been selected from the burst set by the user, otherwise False"""
return bool(self._info["burstPickType"] & BURST_SELECTED)
@property
def burst_key(self):
""" Returns True if photo is a burst photo and is the key image for the burst set (the image that Photos shows on top of the burst stack), otherwise False """
"""Returns True if photo is a burst photo and is the key image for the burst set (the image that Photos shows on top of the burst stack), otherwise False"""
return bool(self._info["burstPickType"] & BURST_KEY)
@property
def burst_default_pick(self):
""" Returns True if photo is a burst image and is the photo that Photos selected as the default image for the burst set, otherwise False """
"""Returns True if photo is a burst image and is the photo that Photos selected as the default image for the burst set, otherwise False"""
return bool(self._info["burstPickType"] & BURST_DEFAULT_PICK)
@property
@@ -742,7 +763,7 @@ class PhotoInfo:
@property
def live_photo(self):
""" Returns True if photo is a live photo, otherwise False """
"""Returns True if photo is a live photo, otherwise False"""
return self._info["live_photo"]
@property
@@ -801,44 +822,98 @@ class PhotoInfo:
return photopath
@property
def path_derivatives(self):
"""Return any derivative (preview) images associated with the photo as a list of paths, sorted by file size (largest first)"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return self._path_derivatives_4()
directory = self._uuid[0] # first char of uuid
derivative_path = (
pathlib.Path(self._db._library_path)
/ "resources"
/ "derivatives"
/ directory
)
files = derivative_path.glob(f"{self.uuid}*.*")
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
# return list of filename but skip .THM files (these are actually low-res thumbnails in JPEG format but with .THM extension)
return [str(filename) for filename in files if filename.suffix != ".THM"]
def _path_derivatives_4(self):
"""Return paths to all derivative (preview) files for Photos <= 4"""
modelid = self._info["modelID"]
if modelid is None:
return []
folder_id, file_id = _get_resource_loc(modelid)
derivatives_root = (
pathlib.Path(self._db._library_path)
/ "resources"
/ "proxies"
/ "derivatives"
/ folder_id
)
# photos appears to usually be in "00" subfolder but
# could be elsewhere--I haven't figured out this logic yet
# first see if it's in 00
derivatives_path = derivatives_root / "00" / file_id
if derivatives_path.is_dir():
files = derivatives_path.glob("*")
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
return [str(filename) for filename in files]
# didn't find derivatives path
for subdir in derivatives_root.glob("*"):
if subdir.is_dir():
derivatives_path = derivatives_root / subdir / file_id
if derivatives_path.is_dir():
files = derivatives_path.glob("*")
files = sorted(files, reverse=True, key=lambda f: f.stat().st_size)
return [str(filename) for filename in files]
# didn't find a derivatives path
return []
@property
def panorama(self):
""" Returns True if photo is a panorama, otherwise False """
"""Returns True if photo is a panorama, otherwise False"""
return self._info["panorama"]
@property
def slow_mo(self):
""" Returns True if photo is a slow motion video, otherwise False """
"""Returns True if photo is a slow motion video, otherwise False"""
return self._info["slow_mo"]
@property
def time_lapse(self):
""" Returns True if photo is a time lapse video, otherwise False """
"""Returns True if photo is a time lapse video, otherwise False"""
return self._info["time_lapse"]
@property
def hdr(self):
""" Returns True if photo is an HDR photo, otherwise False """
"""Returns True if photo is an HDR photo, otherwise False"""
return self._info["hdr"]
@property
def screenshot(self):
""" Returns True if photo is an HDR photo, otherwise False """
"""Returns True if photo is an HDR photo, otherwise False"""
return self._info["screenshot"]
@property
def portrait(self):
""" Returns True if photo is a portrait, otherwise False """
"""Returns True if photo is a portrait, otherwise False"""
return self._info["portrait"]
@property
def selfie(self):
""" Returns True if photo is a selfie (front facing camera), otherwise False """
"""Returns True if photo is a selfie (front facing camera), otherwise False"""
return self._info["selfie"]
@property
def place(self):
""" Returns PlaceInfo object containing reverse geolocation info """
"""Returns PlaceInfo object containing reverse geolocation info"""
# implementation note: doesn't create the PlaceInfo object until requested
# then memoizes the object in self._place to avoid recreating the object
@@ -866,12 +941,12 @@ class PhotoInfo:
@property
def has_raw(self):
""" returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False """
"""returns True if photo has an associated raw image (that is, it's a RAW+JPEG pair), otherwise False"""
return self._info["has_raw"]
@property
def israw(self):
""" returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw """
"""returns True if photo is a raw image. For images with an associated RAW+JPEG pair, see has_raw"""
return "raw-image" in self.uti_original
@property
@@ -883,17 +958,17 @@ class PhotoInfo:
@property
def height(self):
""" returns height of the current photo version in pixels """
"""returns height of the current photo version in pixels"""
return self._info["height"]
@property
def width(self):
""" returns width of the current photo version in pixels """
"""returns width of the current photo version in pixels"""
return self._info["width"]
@property
def orientation(self):
""" returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined """
"""returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined"""
if self._db._db_version <= _PHOTOS_4_VERSION:
return self._info["orientation"]
@@ -909,76 +984,63 @@ class PhotoInfo:
@property
def original_height(self):
""" returns height of the original photo version in pixels """
"""returns height of the original photo version in pixels"""
return self._info["original_height"]
@property
def original_width(self):
""" returns width of the original photo version in pixels """
"""returns width of the original photo version in pixels"""
return self._info["original_width"]
@property
def original_orientation(self):
""" returns EXIF orientation of the original photo version as int """
"""returns EXIF orientation of the original photo version as int"""
return self._info["original_orientation"]
@property
def original_filesize(self):
""" returns filesize of original photo in bytes as int """
"""returns filesize of original photo in bytes as int"""
return self._info["original_filesize"]
@property
def duplicates(self):
"""return list of PhotoInfo objects for possible duplicates (matching signature of original size, date, height, width) or empty list if no matching duplicates"""
signature = self._db._duplicate_signature(self.uuid)
duplicates = []
try:
for uuid in self._db._db_signatures[signature]:
if uuid != self.uuid:
# found a possible duplicate
duplicates.append(self._db.get_photo(uuid))
except KeyError:
# don't expect this to happen as the signature should be in db
logging.warning(f"Did not find signature for {self.uuid} in _db_signatures")
return duplicates
def render_template(
self,
template_str,
none_str="_",
path_sep=None,
expand_inplace=False,
inplace_sep=None,
filename=False,
dirname=False,
strip=False,
edited=False,
self, template_str: str, options: Optional[RenderOptions] = None
):
"""Renders a template string for PhotoInfo instance using PhotoTemplate
Args:
template_str: a template string with fields to render
none_str: a str to use if template field renders to None, default is "_".
path_sep: a single character str to use as path separator when joining
fields like folder_album; if not provided, defaults to 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 white space from resulting template
edited: if True, sets {edited_version} field to True, otherwise it gets set to False; set if you want template evaluated for edited version
options: a RenderOptions instance
Returns:
([rendered_strings], [unmatched]): tuple of list of rendered strings and list of unmatched template values
"""
options = options or RenderOptions()
template = PhotoTemplate(self, exiftool_path=self._db._exiftool_path)
return template.render(
template_str,
none_str=none_str,
path_sep=path_sep,
expand_inplace=expand_inplace,
inplace_sep=inplace_sep,
filename=filename,
dirname=dirname,
strip=strip,
edited_version=edited,
)
return template.render(template_str, options)
@property
def _longitude(self):
""" Returns longitude, in degrees """
"""Returns longitude, in degrees"""
return self._info["longitude"]
@property
def _latitude(self):
""" Returns latitude, in degrees """
"""Returns latitude, in degrees"""
return self._info["latitude"]
def _get_album_uuids(self):
@@ -1016,7 +1078,7 @@ class PhotoInfo:
return f"osxphotos.{self.__class__.__name__}(db={self._db}, uuid='{self._uuid}', info={self._info})"
def __str__(self):
""" string representation of PhotoInfo object """
"""string representation of PhotoInfo object"""
date_iso = self.date.isoformat()
date_modified_iso = (
@@ -1079,7 +1141,7 @@ class PhotoInfo:
return yaml.dump(info, sort_keys=False)
def asdict(self):
""" return dict representation """
"""return dict representation"""
folders = {album.title: album.folder_names for album in self.album_info}
exif = dataclasses.asdict(self.exif_info) if self.exif_info else {}
@@ -1155,7 +1217,7 @@ class PhotoInfo:
}
def json(self):
""" Return JSON representation """
"""Return JSON representation"""
def default(o):
if isinstance(o, (datetime.date, datetime.datetime)):
@@ -1164,7 +1226,7 @@ class PhotoInfo:
return json.dumps(self.asdict(), sort_keys=True, default=default)
def __eq__(self, other):
""" Compare two PhotoInfo objects for equality """
"""Compare two PhotoInfo objects for equality"""
# Can't just compare the two __dicts__ because some methods (like albums)
# memoize their value once called in an instance variable (e.g. self._albums)
if isinstance(other, self.__class__):
@@ -1176,5 +1238,19 @@ class PhotoInfo:
return False
def __ne__(self, other):
""" Compare two PhotoInfo objects for inequality """
"""Compare two PhotoInfo objects for inequality"""
return not self.__eq__(other)
def __hash__(self):
"""Make PhotoInfo hashable"""
return hash(self.uuid)
class PhotoInfoNone:
"""mock class that returns None for all attributes"""
def __init__(self):
pass
def __getattribute__(self, name):
return None

View File

@@ -1,7 +1,10 @@
""" PhotosAlbum class to create an album in default Photos library and add photos to it """
from typing import Optional
from typing import List, Optional
import photoscript
from more_itertools import chunked
from .photoinfo import PhotoInfo
from .utils import noop
@@ -25,50 +28,13 @@ class PhotosAlbum:
f"Added {photo.original_filename} ({photo.uuid}) to album {self.name}"
)
def add_list(self, photo_list: List[PhotoInfo]):
photos = [photoscript.Photo(p.uuid) for p in photo_list]
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}")
def photos(self):
return self.album.photos()
# def add_photo_to_album(photo, album_pairs, results):
# # todo: class PhotoAlbum
# # keeps a name, maintains state
# """ add photo to album(s) as defined in album_pairs
# Args:
# photo: PhotoInfo object
# album_pairs: list of tuples with [(album name, results_list)]
# results: ExportResults object
# Returns:
# updated ExportResults object
# """
# for album, result_list in album_pairs:
# try:
# if album_export is None:
# # first time fetching the album, see if it exists already
# album_export = photos_library.album(
# add_exported_to_album
# )
# if album_export is None:
# # album doesn't exist, so create it
# verbose_(
# f"Creating Photos album '{add_exported_to_album}'"
# )
# album_export = photos_library.create_album(
# add_exported_to_album
# )
# exported_photo = photoscript.Photo(p.uuid)
# album_export.add([exported_photo])
# verbose_(
# f"Added {p.original_filename} ({p.uuid}) to album {add_exported_to_album}"
# )
# exported_album = [
# (filename, add_exported_to_album)
# for filename in export_results.exported
# ]
# export_results.exported_album = exported_album
# if
# except Exception as e:
# click.echo(
# f"Error adding photo {p.original_filename} ({p.uuid}) to album {add_exported_to_album}"
# )

View File

@@ -11,6 +11,7 @@ import platform
import re
import sys
import tempfile
from collections import OrderedDict
from datetime import datetime, timedelta, timezone
from pprint import pformat
from typing import List
@@ -43,6 +44,7 @@ from ..datetime_utils import datetime_has_tz, datetime_naive_to_local
from ..fileutil import FileUtil
from ..personinfo import PersonInfo
from ..photoinfo import PhotoInfo
from ..phototemplate import RenderOptions
from ..queryoptions import QueryOptions
from ..utils import (
_check_file_exists,
@@ -62,29 +64,29 @@ from .photosdb_utils import get_db_model_version, get_db_version
class PhotosDB:
""" Processes a Photos.app library database to extract information about photos """
"""Processes a Photos.app library database to extract information about photos"""
# import additional methods
from ._photosdb_process_comments import _process_comments
from ._photosdb_process_exif import _process_exifinfo
from ._photosdb_process_faceinfo import _process_faceinfo
from ._photosdb_process_scoreinfo import _process_scoreinfo
from ._photosdb_process_searchinfo import (
_process_searchinfo,
labels,
labels_normalized,
labels_as_dict,
labels_normalized,
labels_normalized_as_dict,
)
from ._photosdb_process_scoreinfo import _process_scoreinfo
from ._photosdb_process_comments import _process_comments
def __init__(self, dbfile=None, verbose=None, exiftool=None):
""" Create a new PhotosDB object.
"""Create a new PhotosDB object.
Args:
dbfile: specify full path to photos library or photos.db; if None, will attempt to locate last library opened by Photos.
verbose: optional callable function to use for printing verbose text during processing; if None (default), does not print output.
exiftool: optional path to exiftool for methods that require this (e.g. PhotoInfo.exiftool); if not provided, will search PATH
Raises:
FileNotFoundError if dbfile is not a valid Photos library.
TypeError if verbose is not None and not callable.
@@ -240,6 +242,10 @@ class PhotosDB:
# Will hold the primary key of root folder
self._folder_root_pk = None
# Dict to hold signatures for finding possible duplicates
# key is tuple of (original_filesize, date) and value is list of uuids that match that signature
self._db_signatures = {}
if _debug():
logging.debug(f"dbfile = {dbfile}")
@@ -322,7 +328,7 @@ class PhotosDB:
@property
def keywords_as_dict(self):
""" return keywords as dict of keyword, count in reverse sorted order (descending) """
"""return keywords as dict of keyword, count in reverse sorted order (descending)"""
keywords = {
k: len(self._dbkeywords_keyword[k]) for k in self._dbkeywords_keyword.keys()
}
@@ -332,7 +338,7 @@ class PhotosDB:
@property
def persons_as_dict(self):
""" return persons as dict of person, count in reverse sorted order (descending) """
"""return persons as dict of person, count in reverse sorted order (descending)"""
persons = {}
for pk in self._dbfaces_pk:
fullname = self._dbpersons_pk[pk]["fullname"]
@@ -345,7 +351,7 @@ class PhotosDB:
@property
def albums_as_dict(self):
""" return albums as dict of albums, count in reverse sorted order (descending) """
"""return albums as dict of albums, count in reverse sorted order (descending)"""
albums = {}
album_keys = self._get_album_uuids(shared=False)
for album in album_keys:
@@ -362,8 +368,8 @@ class PhotosDB:
@property
def albums_shared_as_dict(self):
""" returns shared albums as dict of albums, count in reverse sorted order (descending)
valid only on Photos 5; on Photos <= 4, prints warning and returns empty dict """
"""returns shared albums as dict of albums, count in reverse sorted order (descending)
valid only on Photos 5; on Photos <= 4, prints warning and returns empty dict"""
albums = {}
album_keys = self._get_album_uuids(shared=True)
@@ -381,19 +387,19 @@ class PhotosDB:
@property
def keywords(self):
""" return list of keywords found in photos database """
"""return list of keywords found in photos database"""
keywords = self._dbkeywords_keyword.keys()
return list(keywords)
@property
def persons(self):
""" return list of persons found in photos database """
"""return list of persons found in photos database"""
persons = {self._dbpersons_pk[k]["fullname"] for k in self._dbfaces_pk}
return list(persons)
@property
def person_info(self):
""" return list of PersonInfo objects for each person in the photos database """
"""return list of PersonInfo objects for each person in the photos database"""
try:
return self._person_info
except AttributeError:
@@ -404,7 +410,7 @@ class PhotosDB:
@property
def folder_info(self):
""" return list FolderInfo objects representing top-level folders in the photos database """
"""return list FolderInfo objects representing top-level folders in the photos database"""
if self._db_version <= _PHOTOS_4_VERSION:
folders = [
FolderInfo(db=self, uuid=folder)
@@ -425,7 +431,7 @@ class PhotosDB:
@property
def folders(self):
""" return list of top-level folder names in the photos database """
"""return list of top-level folder names in the photos database"""
if self._db_version <= _PHOTOS_4_VERSION:
folder_names = [
folder["name"]
@@ -446,7 +452,7 @@ class PhotosDB:
@property
def album_info(self):
""" return list of AlbumInfo objects for each album in the photos database """
"""return list of AlbumInfo objects for each album in the photos database"""
try:
return self._album_info
except AttributeError:
@@ -458,8 +464,8 @@ class PhotosDB:
@property
def album_info_shared(self):
""" return list of AlbumInfo objects for each shared album in the photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
"""return list of AlbumInfo objects for each shared album in the photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list"""
# if _dbalbum_details[key]["cloudownerhashedpersonid"] is not None, then it's a shared album
try:
return self._album_info_shared
@@ -472,7 +478,7 @@ class PhotosDB:
@property
def albums(self):
""" return list of albums found in photos database """
"""return list of albums found in photos database"""
# Could be more than one album with same name
# Right now, they are treated as same album and photos are combined from albums with same name
@@ -485,8 +491,8 @@ class PhotosDB:
@property
def albums_shared(self):
""" return list of shared albums found in photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list """
"""return list of shared albums found in photos database
only valid for Photos 5; on Photos <= 4, prints warning and returns empty list"""
# Could be more than one album with same name
# Right now, they are treated as same album and photos are combined from albums with same name
@@ -501,7 +507,7 @@ class PhotosDB:
@property
def import_info(self):
""" return list of ImportInfo objects for each import session in the database """
"""return list of ImportInfo objects for each import session in the database"""
try:
return self._import_info
except AttributeError:
@@ -513,21 +519,21 @@ class PhotosDB:
@property
def db_version(self):
""" return the database version as stored in LiGlobals table """
"""return the database version as stored in LiGlobals table"""
return self._db_version
@property
def db_path(self):
""" returns path to the Photos library database PhotosDB was initialized with """
"""returns path to the Photos library database PhotosDB was initialized with"""
return os.path.abspath(self._dbfile)
@property
def library_path(self):
""" returns path to the Photos library PhotosDB was initialized with """
"""returns path to the Photos library PhotosDB was initialized with"""
return self._library_path
def get_db_connection(self):
""" Get connection to the working copy of the Photos database
"""Get connection to the working copy of the Photos database
Returns:
tuple of (connection, cursor) to sqlite3 database
@@ -535,7 +541,7 @@ class PhotosDB:
return _open_sql_file(self._tmp_db)
def _copy_db_file(self, fname):
""" copies the sqlite database file to a temp file """
"""copies the sqlite database file to a temp file"""
""" returns the name of the temp file """
""" If sqlite shared memory and write-ahead log files exist, those are copied too """
# required because python's sqlite3 implementation can't read a locked file
@@ -587,8 +593,8 @@ class PhotosDB:
# return dest_path
def _process_database4(self):
""" process the Photos database to extract info
works on Photos version <= 4.0 """
"""process the Photos database to extract info
works on Photos version <= 4.0"""
verbose = self._verbose
verbose("Processing database.")
@@ -900,7 +906,8 @@ class PhotosDB:
RKVersion.subType,
RKVersion.inTrashDate,
RKVersion.showInLibrary,
RKMaster.fileIsReference
RKMaster.fileIsReference,
RKMaster.importGroupUuid
FROM RKVersion, RKMaster
WHERE RKVersion.masterUuid = RKMaster.uuid"""
)
@@ -931,7 +938,8 @@ class PhotosDB:
RKVersion.subType,
RKVersion.inTrashDate,
RKVersion.showInLibrary,
RKMaster.fileIsReference
RKMaster.fileIsReference,
RKMaster.importGroupUuid
FROM RKVersion, RKMaster
WHERE RKVersion.masterUuid = RKMaster.uuid"""
)
@@ -981,6 +989,7 @@ class PhotosDB:
# 41 RKVersion.inTrashDate
# 42 RKVersion.showInLibrary -- is item visible in library (e.g. non-selected burst images are not visible)
# 43 RKMaster.fileIsReference -- file is reference (imported without copying to Photos library)
# 44 RKMaster.importGroupUuid -- to get date added from RKImportGroup
for row in c:
uuid = row[0]
@@ -1174,9 +1183,16 @@ class PhotosDB:
# import session not yet handled for Photos 4
self._dbphotos[uuid]["import_session"] = None
self._dbphotos[uuid]["import_uuid"] = None
self._dbphotos[uuid]["import_uuid"] = row[44]
self._dbphotos[uuid]["fok_import_session"] = None
# compute signatures for finding possible duplicates
signature = self._duplicate_signature(uuid)
try:
self._db_signatures[signature].append(uuid)
except KeyError:
self._db_signatures[signature] = [uuid]
# get additional details from RKMaster, needed for RAW processing
verbose("Processing additional photo details.")
c.execute(
@@ -1364,11 +1380,17 @@ class PhotosDB:
# get the place data
place_data = c.execute(
"SELECT modelID, defaultName, type, area " "FROM RKPlace "
"SELECT modelID, defaultName, type, area FROM RKPlace"
).fetchall()
places = {p[0]: p for p in place_data}
self._db_places = places
# get import data
import_data = c.execute(
"SELECT modelID, uuid, name, importDate from RKImportGroup"
).fetchall()
self._db_import_group = {i[1]: i for i in import_data}
for uuid in self._dbphotos:
# get placeId which is then used to lookup defaultName
place_ids_query = c.execute(
@@ -1402,6 +1424,17 @@ class PhotosDB:
self._dbphotos[uuid]["placeNames"] = place_names
self._dbphotos[uuid]["reverse_geolocation"] = None # Photos 5
# add date added
try:
import_session = self._db_import_group[
self._dbphotos[uuid]["import_uuid"]
]
self._dbphotos[uuid]["added_date"] = datetime.fromtimestamp(
import_session[3] + TIME_DELTA
)
except (ValueError, TypeError, KeyError):
self._dbphotos[uuid]["added_date"] = datetime(1970, 1, 1)
# build album_titles dictionary
for album_id in self._dbalbum_details:
title = self._dbalbum_details[album_id]["title"]
@@ -1512,15 +1545,15 @@ class PhotosDB:
logging.debug(pformat(self._dbphotos_burst))
def _build_album_folder_hierarchy_4(self, uuid, folders=None):
""" recursively build folder/album hierarchy
uuid: parent uuid of the album being processed
(parent uuid is a folder in RKFolders)
folders: dict holding the folder hierarchy
NOTE: This implementation is different than _build_album_folder_hierarchy_5
which takes the uuid of the album being processed. Here uuid is the parent uuid
of the parent folder album because in Photos <=4, folders are in RKFolders and
albums in RKAlbums. In Photos 5, folders are just special albums
with kind = _PHOTOS_5_FOLDER_KIND """
"""recursively build folder/album hierarchy
uuid: parent uuid of the album being processed
(parent uuid is a folder in RKFolders)
folders: dict holding the folder hierarchy
NOTE: This implementation is different than _build_album_folder_hierarchy_5
which takes the uuid of the album being processed. Here uuid is the parent uuid
of the parent folder album because in Photos <=4, folders are in RKFolders and
albums in RKAlbums. In Photos 5, folders are just special albums
with kind = _PHOTOS_5_FOLDER_KIND"""
parent_uuid = self._dbfolder_details[uuid]["parentFolderUuid"]
@@ -1543,11 +1576,11 @@ class PhotosDB:
return folders
def _process_database5(self):
""" process the Photos database to extract info
works on Photos version 5 and version 6
"""process the Photos database to extract info
works on Photos version 5 and version 6
This is a big hairy 700 line function that should probably be refactored
but it works so don't touch it.
This is a big hairy 700 line function that should probably be refactored
but it works so don't touch it.
"""
if _debug():
@@ -1595,7 +1628,11 @@ class PhotosDB:
for person in c:
pk = person[0]
fullname = person[2] if person[2] != "" else _UNKNOWN_PERSON
fullname = (
person[2]
if (person[2] != "" and person[2] is not None)
else _UNKNOWN_PERSON
)
self._dbpersons_pk[pk] = {
"pk": pk,
"uuid": person[1],
@@ -1867,7 +1904,8 @@ class PhotosDB:
{asset_table}.ZADJUSTMENTTIMESTAMP,
{asset_table}.ZVISIBILITYSTATE,
{asset_table}.ZTRASHEDDATE,
{asset_table}.ZSAVEDASSETTYPE
{asset_table}.ZSAVEDASSETTYPE,
{asset_table}.ZADDEDDATE
FROM {asset_table}
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = {asset_table}.Z_PK
ORDER BY {asset_table}.ZUUID """
@@ -1915,6 +1953,7 @@ class PhotosDB:
# 38 ZGENERICASSET.ZVISIBILITYSTATE -- 0 if visible, 2 if not (e.g. a burst image)
# 39 ZGENERICASSET.ZTRASHEDDATE -- date item placed in the trash or null if not in trash
# 40 ZGENERICASSET.ZSAVEDASSETTYPE -- how item imported
# 41 ZGENERICASSET.ZADDEDDATE -- date item added to the library
for row in c:
uuid = row[0]
@@ -2094,6 +2133,11 @@ class PhotosDB:
info["saved_asset_type"] = row[40]
info["isreference"] = row[40] == 10
try:
info["added_date"] = datetime.fromtimestamp(row[41] + TIME_DELTA)
except (ValueError, TypeError):
info["added_date"] = datetime(1970, 1, 1)
# initialize import session info which will be filled in later
# not every photo has an import session so initialize all records now
info["import_session"] = None
@@ -2114,6 +2158,13 @@ class PhotosDB:
self._dbphotos[uuid] = info
# compute signatures for finding possible duplicates
signature = self._duplicate_signature(uuid)
try:
self._db_signatures[signature].append(uuid)
except KeyError:
self._db_signatures[signature] = [uuid]
# # if row[19] is not None and ((row[20] == 2) or (row[20] == 4)):
# # burst photo
# if row[19] is not None:
@@ -2399,9 +2450,9 @@ class PhotosDB:
logging.debug(pformat(self._dbphotos_burst))
def _build_album_folder_hierarchy_5(self, uuid, folders=None):
""" recursively build folder/album hierarchy
uuid: uuid of the album/folder being processed
folders: dict holding the folder hierarchy """
"""recursively build folder/album hierarchy
uuid: uuid of the album/folder being processed
folders: dict holding the folder hierarchy"""
# get parent uuid
parent = self._dbalbum_details[uuid]["parentfolder"]
@@ -2422,17 +2473,17 @@ class PhotosDB:
return folders
def _album_folder_hierarchy_list(self, album_uuid):
""" return appropriate album_folder_hierarchy_list for the _db_version """
"""return appropriate album_folder_hierarchy_list for the _db_version"""
if self._db_version <= _PHOTOS_4_VERSION:
return self._album_folder_hierarchy_list_4(album_uuid)
else:
return self._album_folder_hierarchy_list_5(album_uuid)
def _album_folder_hierarchy_list_4(self, album_uuid):
""" return hierarchical list of folder names album_uuid is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
"""return hierarchical list of folder names album_uuid is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders"""
try:
folders = self._dbalbum_folders[album_uuid]
except KeyError:
@@ -2440,7 +2491,7 @@ class PhotosDB:
return []
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
"""recursively walk the folders dict to build list of folder hierarchy"""
if not folders:
# empty folder dict (album has no folder hierarchy)
return []
@@ -2466,10 +2517,10 @@ class PhotosDB:
return hierarchy
def _album_folder_hierarchy_list_5(self, album_uuid):
""" return hierarchical list of folder names album_uuid is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
"""return hierarchical list of folder names album_uuid is contained in
the folder list is in form:
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders"""
try:
folders = self._dbalbum_folders[album_uuid]
except KeyError:
@@ -2477,7 +2528,7 @@ class PhotosDB:
return []
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
"""recursively walk the folders dict to build list of folder hierarchy"""
if not folders:
# empty folder dict (album has no folder hierarchy)
@@ -2509,15 +2560,15 @@ class PhotosDB:
return self._album_folder_hierarchy_folderinfo_5(album_uuid)
def _album_folder_hierarchy_folderinfo_4(self, album_uuid):
""" return hierarchical list of FolderInfo objects album_uuid is contained in
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
"""return hierarchical list of FolderInfo objects album_uuid is contained in
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders"""
# title = photosdb._dbalbum_details[album_uuid]["title"]
folders = self._dbalbum_folders[album_uuid]
# logging.warning(f"uuid = {album_uuid}, folder = {folders}")
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
"""recursively walk the folders dict to build list of folder hierarchy"""
# logging.warning(f"folders={folders},hierarchy = {hierarchy}")
if not folders:
# empty folder dict (album has no folder hierarchy)
@@ -2543,14 +2594,14 @@ class PhotosDB:
return hierarchy
def _album_folder_hierarchy_folderinfo_5(self, album_uuid):
""" return hierarchical list of FolderInfo objects album_uuid is contained in
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders """
"""return hierarchical list of FolderInfo objects album_uuid is contained in
["Top level folder", "sub folder 1", "sub folder 2"]
returns empty list of album is not in any folders"""
# title = photosdb._dbalbum_details[album_uuid]["title"]
folders = self._dbalbum_folders[album_uuid]
def _recurse_folder_hierarchy(folders, hierarchy=[]):
""" recursively walk the folders dict to build list of folder hierarchy """
"""recursively walk the folders dict to build list of folder hierarchy"""
if not folders:
# empty folder dict (album has no folder hierarchy)
@@ -2575,19 +2626,19 @@ class PhotosDB:
return hierarchy
def _get_album_uuids(self, shared=False, import_session=False):
""" Return list of album UUIDs found in photos database
"""Return list of album UUIDs found in photos database
Filters out albums in the trash and any special album types
Args:
shared: boolean; if True, returns shared albums, else normal albums
import_session: boolean, if True, returns import session albums, else normal or shared albums
Note: flags (shared, import_session) are mutually exclusive
Raises:
ValueError: raised if mutually exclusive flags passed
Returns: list of album UUIDs
Returns: list of album UUIDs
"""
if shared and import_session:
raise ValueError(
@@ -2639,14 +2690,14 @@ class PhotosDB:
return album_list
def _get_albums(self, shared=False):
""" Return list of album titles found in photos database
"""Return list of album titles found in photos database
Albums may have duplicate titles -- these will be treated as a single album.
Filters out albums in the trash and any special album types
Args:
shared: boolean; if True, returns shared albums, else normal albums
Returns: list of album names
"""
@@ -2665,7 +2716,7 @@ class PhotosDB:
to_date=None,
intrash=False,
):
""" Return a list of PhotoInfo objects
"""Return a list of PhotoInfo objects
If called with no args, returns the entire database of photos
If called with args, returns photos matching the args (e.g. keywords, persons, etc.)
If more than one arg, returns photos matching all the criteria (e.g. keywords AND persons)
@@ -2680,10 +2731,10 @@ class PhotosDB:
persons: list of persons to search for
albums: list of album names to search for
images: if True, returns image files, if False, does not return images; default is True
movies: if True, returns movie files, if False, does not return movies; default is True
movies: if True, returns movie files, if False, does not return movies; default is True
from_date: return photos with creation date >= from_date (datetime.datetime object, default None)
to_date: return photos with creation date <= to_date (datetime.datetime object, default None)
intrash: if True, returns only images in "Recently deleted items" folder,
intrash: if True, returns only images in "Recently deleted items" folder,
if False returns only photos that aren't deleted; default is False
Returns:
@@ -2790,7 +2841,7 @@ class PhotosDB:
return photoinfo
def get_photo(self, uuid):
""" Returns a single photo matching uuid
"""Returns a single photo matching uuid
Arguments:
uuid: the UUID of photo to get
@@ -2805,7 +2856,7 @@ class PhotosDB:
# TODO: add to docs and test
def photos_by_uuid(self, uuids):
""" Returns a list of photos with UUID in uuids.
"""Returns a list of photos with UUID in uuids.
Does not generate error if invalid or missing UUID passed.
This is faster than using PhotosDB.photos if you have list of UUIDs.
Returns photos regardless of intrash state.
@@ -3157,11 +3208,12 @@ class PhotosDB:
if options.regex:
flags = re.IGNORECASE if options.ignore_case else 0
render_options = RenderOptions(none_str="")
for regex, template in options.regex:
regex = re.compile(regex, flags)
photo_list = []
for p in photos:
rendered, _ = p.render_template(template, none_str="")
rendered, _ = p.render_template(template, render_options)
for value in rendered:
if regex.search(value):
photo_list.append(p)
@@ -3176,8 +3228,45 @@ class PhotosDB:
except Exception as e:
raise ValueError(f"Invalid query_eval CRITERIA: {e}")
if options.duplicate:
no_date = datetime(1970, 1, 1)
tz = timezone(timedelta(0))
no_date = no_date.astimezone(tz=tz)
photos = sorted(
[p for p in photos if p.duplicates],
key=lambda x: x.date_added or no_date,
)
# gather all duplicates but ensure each uuid is only represented once
photodict = OrderedDict()
for p in photos:
if p.uuid not in photodict:
photodict[p.uuid] = p
for d in sorted(
p.duplicates, key=lambda x: x.date_added or no_date
):
if d.uuid not in photodict:
photodict[d.uuid] = d
photos = list(photodict.values())
# filter for deleted as photo.duplicates will include photos in the trash
if not (options.deleted or options.deleted_only):
photos = [p for p in photos if not p.intrash]
if options.deleted_only:
photos = [p for p in photos if p.intrash]
return photos
def _duplicate_signature(self, uuid):
"""Compute a signature for finding possible duplicates"""
return (
self._dbphotos[uuid]["original_filesize"],
self._dbphotos[uuid]["imageDate"],
self._dbphotos[uuid]["height"],
self._dbphotos[uuid]["width"],
self._dbphotos[uuid]["UTI"],
self._dbphotos[uuid]["hasAdjustments"],
)
def __repr__(self):
return f"osxphotos.{self.__class__.__name__}(dbfile='{self.db_path}')"
@@ -3189,8 +3278,8 @@ class PhotosDB:
return False
def __len__(self):
""" Returns number of photos in the database
Includes recently deleted photos and non-selected burst images
"""Returns number of photos in the database
Includes recently deleted photos and non-selected burst images
"""
return len(self._dbphotos)
@@ -3220,4 +3309,4 @@ def _get_photos_by_attribute(photos, attribute, values, ignore_case):
else:
for x in values:
photos_search.extend(p for p in photos if x in getattr(p, attribute))
return photos_search
return list(set(photos_search))

View File

@@ -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 -->

View File

@@ -4,14 +4,21 @@ import datetime
import locale
import os
import pathlib
import sys
import shlex
from textx import TextXSyntaxError, metamodel_from_file
from ._constants import _UNKNOWN_PERSON
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 dataclasses import dataclass
from typing import Optional
# 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, "")
@@ -134,6 +141,13 @@ 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)
@@ -160,6 +174,7 @@ 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.",
"{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.",
"{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 +190,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 +199,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,20 +234,53 @@ 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
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
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
@@ -234,15 +288,15 @@ class PhotoTemplateParser:
self.metamodel = metamodel_from_file(OTL_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)
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 +312,51 @@ 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.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
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.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
try:
model = self.parser.parse(template)
except TextXSyntaxError as e:
@@ -310,53 +366,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 +398,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 +410,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 +438,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 +454,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 +471,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:
@@ -474,10 +487,8 @@ class PhotoTemplate:
vals = self.get_template_value(
field,
default=default,
delim=delim or inplace_sep,
path_sep=path_sep,
filename=filename,
dirname=dirname,
# delim=delim or self.inplace_sep,
# path_sep=path_sep,
)
elif field == "exiftool":
if subfield is None:
@@ -485,7 +496,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,20 +504,22 @@ 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, 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
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))]
for filter_ in filters:
@@ -527,7 +540,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,7 +555,7 @@ 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}"
@@ -603,7 +616,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,29 +641,29 @@ class PhotoTemplate:
self,
field,
default,
bool_val=None,
delim=None,
path_sep=None,
filename=False,
dirname=False,
# 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
Returns:
The matching template value (which may be None).
Raises:
ValueError if no rule exists for field.
"""
if self.photo.uuid is None:
return []
if field not in FIELD_NAMES:
raise ValueError(f"SyntaxError: Unknown field: {field}")
@@ -908,13 +921,48 @@ class PhotoTemplate:
value = self.photo.uuid
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)
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 +1008,25 @@ 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, path_sep, default):
"""lookup value for template field (multi-value template substitutions)
Args:
field: template field to find value for.
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,6 +1035,10 @@ 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
@@ -1005,7 +1062,7 @@ class PhotoTemplate:
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
@@ -1017,7 +1074,7 @@ class PhotoTemplate:
values.append(folder)
else:
# album not in folder
if dirname:
if self.dirname:
values.append(sanitize_dirname(album.title))
else:
values.append(album.title)
@@ -1035,6 +1092,8 @@ 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.startswith("photo"):
# provide access to PhotoInfo object
properties = field.split(".")
@@ -1065,9 +1124,9 @@ class PhotoTemplate:
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 != "folder_album":
# skip folder_album because it would have been handled above
values = [sanitize_dirname(value) for value in values]
@@ -1075,9 +1134,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 +1155,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(
@@ -1123,17 +1191,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(
@@ -1158,9 +1227,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 +1236,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 +1270,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 +1295,38 @@ 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}")

View File

@@ -1,6 +1,6 @@
""" QueryOptions class for PhotosDB.query """
from dataclasses import dataclass
from dataclasses import dataclass, asdict
from typing import Optional, Iterable, Tuple
import datetime
import bitmath
@@ -30,7 +30,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 +78,7 @@ 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
def asdict(self):
return asdict(self)

View File

@@ -268,14 +268,23 @@ def list_photo_libraries():
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 """
returns: preferred extension as str or None if cannot be determined """
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
with objc.autorelease_pool():
return CoreServices.UTTypeCopyPreferredTagWithClass(
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
def findfiles(pattern, path_):
"""Returns list of filenames from path_ matched by pattern

View File

@@ -1,211 +1,22 @@
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
pyobjc-core==7.2
pyobjc-framework-AppleScriptKit==7.2
pyobjc-framework-AppleScriptObjC==7.2
pyobjc-framework-Photos==7.2
pyobjc-framework-Quartz==7.2
pyobjc-framework-AVFoundation==7.2
pyobjc-framework-CoreServices==7.2
pyobjc-framework-Metal==7.2
Click==8.0.1
PyYAML==5.4.1
Mako==1.1.4
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
pathvalidate==2.4.1
dataclasses==0.7;python_version<'3.7'
wurlitzer==2.1.0
photoscript==0.1.3
toml==0.10.2
osxmetadata==0.99.14
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
rich==10.2.2
bitmath==1.3.3.1
more-itertools==8.8.0

View File

@@ -73,20 +73,28 @@ setup(
"Topic :: Software Development :: Libraries :: Python Modules",
],
install_requires=[
"pyobjc>=6.2.2",
"Click>=7",
"PyYAML>=5.1.2",
"Mako>=1.1.1",
"pyobjc-core==7.2",
"pyobjc-framework-AppleScriptKit==7.2",
"pyobjc-framework-AppleScriptObjC==7.2",
"pyobjc-framework-Photos==7.2",
"pyobjc-framework-Quartz==7.2",
"pyobjc-framework-AVFoundation==7.2",
"pyobjc-framework-CoreServices==7.2",
"pyobjc-framework-Metal==7.2",
"Click==8.0.1",
"PyYAML==5.4.1",
"Mako==1.1.4",
"bpylist2==3.0.2",
"pathvalidate==2.2.1",
"pathvalidate==2.4.1",
"dataclasses==0.7;python_version<'3.7'",
"wurlitzer>=2.0.1",
"photoscript>=0.1.2",
"toml>=0.10.0",
"osxmetadata>=0.99.13",
"wurlitzer==2.1.0",
"photoscript==0.1.3",
"toml==0.10.2",
"osxmetadata==0.99.14",
"textx==2.3.0",
"rich>=9.11.1",
"rich==10.2.2",
"bitmath==1.3.3.1",
"more-itertools==8.8.0",
],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
include_package_data=True,

View File

@@ -5,7 +5,7 @@
<key>LithiumMessageTracer</key>
<dict>
<key>LastReportedDate</key>
<date>2020-04-17T18:39:50Z</date>
<date>2021-06-01T17:42:08Z</date>
</dict>
<key>PXPeopleScreenUnlocked</key>
<true/>

View File

@@ -11,6 +11,6 @@
<key>PLLastRevGeoForcedProviderOutOfDateCheckVersionKey</key>
<integer>1</integer>
<key>PLLastRevGeoVerFileFetchDateKey</key>
<date>2020-04-17T18:39:52Z</date>
<date>2021-06-01T17:42:08Z</date>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>LastHistoryRowId</key>
<integer>502</integer>
<integer>517</integer>
<key>LibraryBuildTag</key>
<string>E3E46F2A-7168-4973-AB3E-5848F80BFC7D</string>
<key>LibrarySchemaVersion</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

View File

@@ -5,9 +5,9 @@
<key>hostname</key>
<string>Rhets-MacBook-Pro.local</string>
<key>hostuuid</key>
<string>9575E48B-8D5F-5654-ABAC-4431B1167324</string>
<string>585B80BF-8D1F-55EF-A9E8-6CF4E5523959</string>
<key>pid</key>
<integer>86501</integer>
<integer>570</integer>
<key>processname</key>
<string>photolibraryd</string>
<key>uid</key>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -3,9 +3,10 @@ import os
import pathlib
import pytest
from applescript import AppleScript
from photoscript.utils import ditto
import osxphotos
from applescript import AppleScript
from osxphotos.exiftool import _ExifToolProc
@@ -34,7 +35,7 @@ if OS_VER == "15":
TEST_LIBRARY = "tests/Test-10.15.7.photoslibrary"
else:
TEST_LIBRARY = None
pytest.exit("This test suite currently only runs on MacOS Catalina ")
# pytest.exit("This test suite currently only runs on MacOS Catalina ")
@pytest.fixture(autouse=True)
@@ -59,10 +60,13 @@ def pytest_configure(config):
def pytest_collection_modifyitems(config, items):
if config.getoption("--addalbum"):
if config.getoption("--addalbum") and TEST_LIBRARY is not None:
# --addalbum given in cli: do not skip addalbum tests (these require interactive test)
return
skip_addalbum = pytest.mark.skip(reason="need --addalbum option to run")
skip_addalbum = pytest.mark.skip(
reason="need --addalbum option and MacOS Catalina to run"
)
for item in items:
if "addalbum" in item.keywords:
item.add_marker(skip_addalbum)

View File

@@ -10,9 +10,9 @@ import json
import osxphotos
UUID = [
"C8EAF50A-D891-4E0C-8086-C417E1284153",
"71DFB4C3-E868-4BE4-906E-D96BD8692D7E",
"2C151013-5BBA-4D00-B70F-1C9420418B86",
"DC09F4D8-6173-452D-AC15-725C8D7C185E",
"AFECD4AB-937C-46AF-A79B-9C9A38AA42B1",
"A1C36260-92CD-47E2-927A-35DAF16D7882",
]
data = {

View File

@@ -8,6 +8,7 @@ class PhotoInfoMock(PhotoInfo):
self._photo = photo
self._db = photo._db
self._info = photo._info
self._uuid = photo.uuid
for kw in kwargs:
if hasattr(photo, kw):

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
[{"EXIF:ImageDescription": "Girl holding pumpkin", "XMP:Description": "Girl holding pumpkin", "IPTC:Caption-Abstract": "Girl holding pumpkin", "XMP:Title": "I found one!", "IPTC:ObjectName": "I found one!", "IPTC:Keywords": ["Kids", "Pumpkin Farm", "Test Album"], "XMP:Subject": ["Kids", "Pumpkin Farm", "Test Album"], "XMP:TagsList": ["Kids", "Pumpkin Farm", "Test Album"], "XMP:PersonInImage": ["Katie"], "EXIF:GPSLatitude": 41.256566, "EXIF:GPSLongitude": -95.940257, "EXIF:GPSLatitudeRef": "N", "EXIF:GPSLongitudeRef": "W", "EXIF:DateTimeOriginal": "2018:09:28 16:07:07", "EXIF:CreateDate": "2018:09:28 16:07:07", "EXIF:OffsetTimeOriginal": "-04:00", "IPTC:DateCreated": "2018:09:28", "IPTC:TimeCreated": "16:07:07-04:00", "EXIF:ModifyDate": "2018:09:28 16:07:07"}]
[{"EXIF:ImageDescription": "Girl holding pumpkin", "XMP:Description": "Girl holding pumpkin", "IPTC:Caption-Abstract": "Girl holding pumpkin", "XMP:Title": "I found one!", "IPTC:ObjectName": "I found one!", "IPTC:Keywords": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"], "XMP:Subject": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"], "XMP:TagsList": ["Kids", "Multi Keyword", "Pumpkin Farm", "Test Album"], "XMP:PersonInImage": ["Katie"], "EXIF:GPSLatitude": 41.256566, "EXIF:GPSLongitude": -95.940257, "EXIF:GPSLatitudeRef": "N", "EXIF:GPSLongitudeRef": "W", "EXIF:DateTimeOriginal": "2018:09:28 16:07:07", "EXIF:CreateDate": "2018:09:28 16:07:07", "EXIF:OffsetTimeOriginal": "-04:00", "IPTC:DateCreated": "2018:09:28", "IPTC:TimeCreated": "16:07:07-04:00", "EXIF:ModifyDate": "2018:09:28 16:07:07"}]

Some files were not shown because too many files have changed in this diff Show More