Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8ea0b0452 | ||
|
|
81fd51c793 | ||
|
|
648d399524 | ||
|
|
345c052353 | ||
|
|
952f1a6c3c | ||
|
|
7ae5b8aae7 | ||
|
|
2e189d771e | ||
|
|
7fa7de1563 | ||
|
|
70d68a25ba | ||
|
|
bfc4371d9e | ||
|
|
6a288676a1 | ||
|
|
874ad2fa34 | ||
|
|
a233167471 | ||
|
|
21dc0d388f | ||
|
|
eff8e7a63f |
@@ -193,6 +193,15 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ubrandes",
|
||||
"name": "ubrandes ",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/59647284?v=4",
|
||||
"profile": "https://github.com/ubrandes",
|
||||
"contributions": [
|
||||
"ideas"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [v0.42.4](https://github.com/RhetTbull/osxphotos/compare/v0.42.3...v0.42.4)
|
||||
|
||||
> 18 April 2021
|
||||
|
||||
- Added --min-size, --max-size query options, #425 [`7ae5b8a`](https://github.com/RhetTbull/osxphotos/commit/7ae5b8aae78621c5b7501f9faa5e0f7f4d815ba1)
|
||||
- Updated docs, added build.sh [`2e189d7`](https://github.com/RhetTbull/osxphotos/commit/2e189d771edaf18c1ebffd558e3e84e43bff2f08)
|
||||
- Fixed setup.py [`952f1a6`](https://github.com/RhetTbull/osxphotos/commit/952f1a6c3c3f3c7a55c0a270e73a13c4da6d2375)
|
||||
|
||||
#### [v0.42.3](https://github.com/RhetTbull/osxphotos/compare/v0.42.2...v0.42.3)
|
||||
|
||||
> 17 April 2021
|
||||
|
||||
- Updated docs, closes #424 [`#424`](https://github.com/RhetTbull/osxphotos/issues/424)
|
||||
- Added {newline}, #426 [`7fa7de1`](https://github.com/RhetTbull/osxphotos/commit/7fa7de15631958a973514fe1a9c2cbf4301b6301)
|
||||
|
||||
#### [v0.42.2](https://github.com/RhetTbull/osxphotos/compare/v0.42.1...v0.42.2)
|
||||
|
||||
> 17 April 2021
|
||||
|
||||
- Fixed bug for multi-field templates and --xattr-template, #422 [`6a28867`](https://github.com/RhetTbull/osxphotos/commit/6a288676a14ce23380181d43db19128afdda7731)
|
||||
- Add @ubrandes as a contributor [`874ad2f`](https://github.com/RhetTbull/osxphotos/commit/874ad2fa34d8306c071cd479625a9aa97f6488b2)
|
||||
|
||||
#### [v0.42.1](https://github.com/RhetTbull/osxphotos/compare/v0.41.11...v0.42.1)
|
||||
|
||||
> 15 April 2021
|
||||
|
||||
- Implements conditional expressions for template system, #417 [`03f8b2b`](https://github.com/RhetTbull/osxphotos/commit/03f8b2bc6ed53d3176f9d1ac51c3e4469db3e94b)
|
||||
- Added {function} template, #419 [`21dc0d3`](https://github.com/RhetTbull/osxphotos/commit/21dc0d388f508c33526ba7510d78c71abd1151a9)
|
||||
- Added template_function.py to examples [`eff8e7a`](https://github.com/RhetTbull/osxphotos/commit/eff8e7a63ff77e80fff0ce53fe56f5a010f55ab5)
|
||||
|
||||
#### [v0.41.11](https://github.com/RhetTbull/osxphotos/compare/v0.41.10...v0.41.11)
|
||||
|
||||
> 12 April 2021
|
||||
|
||||
50
README.md
50
README.md
@@ -4,7 +4,7 @@
|
||||
[](https://github.com/RhetTbull/osxphotos/workflows/Tests/badge.svg)
|
||||

|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors)
|
||||
[](#contributors)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
OSXPhotos provides the ability to interact with and query Apple's Photos.app library on macOS. You can query the Photos library database — for example, file name, file path, and metadata such as keywords/tags, persons/faces, albums, etc. You can also easily export both the original and edited photos.
|
||||
@@ -315,6 +315,22 @@ Options:
|
||||
albums.
|
||||
|
||||
--not-in-album Search for photos that are not in any albums.
|
||||
--min-size SIZE Search for photos with size >= SIZE bytes. The
|
||||
size evaluated is the photo's original size
|
||||
(when imported to Photos). Size may be
|
||||
specified as integer bytes or using SI or NIST
|
||||
units. For example, the following are all
|
||||
valid and equivalent sizes: '1048576'
|
||||
'1.048576MB', '1 MiB'.
|
||||
|
||||
--max-size SIZE Search for photos with size <= SIZE bytes. The
|
||||
size evaluated is the photo's original size
|
||||
(when imported to Photos). Size may be
|
||||
specified as integer bytes or using SI or NIST
|
||||
units. For example, the following are all
|
||||
valid and equivalent sizes: '1048576'
|
||||
'1.048576MB', '1 MiB'.
|
||||
|
||||
--query-eval CRITERIA Evaluate CRITERIA to filter photos. CRITERIA
|
||||
will be evaluated in context of the following
|
||||
python list comprehension: `photos = [photo
|
||||
@@ -858,7 +874,7 @@ e.g. If Photo is in Album1 in Folder1:
|
||||
• "{folder_album(>)}" renders to ["Folder1>Album1"]
|
||||
• "{folder_album()}" renders to ["Folder1Album1"]
|
||||
|
||||
[find|replace]: optional text replacement to perform on rendered template value.
|
||||
[find,replace]: optional text replacement to perform on rendered template value.
|
||||
For example, to replace "/" in an album name, you could use the template
|
||||
"{album[/,-]}". Multiple replacements can be made by appending "|" and adding
|
||||
another find|replace pair. e.g. to replace both "/" and ":" in album name:
|
||||
@@ -1219,6 +1235,10 @@ Substitution Description
|
||||
{closeparens} A close parentheses: ')'
|
||||
{openbracket} An open bracket: '['
|
||||
{closebracket} A close bracket: ']'
|
||||
{newline} A newline: '\n'
|
||||
{lf} A line feed: '\n', alias for {newline}
|
||||
{cr} A carriage return: '\r'
|
||||
{crlf} a carriage return + line feed: '\r\n'
|
||||
|
||||
The following substitutions may result in multiple values. Thus if specified for
|
||||
--directory these could result in multiple copies of a photo being being
|
||||
@@ -1236,7 +1256,11 @@ Substitution Description
|
||||
{keyword} Keyword(s) assigned to photo
|
||||
{person} Person(s) / face(s) in a photo
|
||||
{label} Image categorization label associated with a photo
|
||||
(Photos 5+ only)
|
||||
(Photos 5+ only). Labels are added automatically by
|
||||
Photos using machine learning algorithms to
|
||||
categorize images. These are not the same as
|
||||
{keyword} which refers to the user-defined
|
||||
keywords/tags applied in Photos.
|
||||
|
||||
{label_normalized} All lower case version of 'label' (Photos 5+ only)
|
||||
{comment} Comment(s) on shared Photos; format is 'Person name:
|
||||
@@ -1281,6 +1305,16 @@ Substitution Description
|
||||
https://rhettbull.github.io/osxphotos/ for additional
|
||||
documentation on the PhotoInfo class.
|
||||
|
||||
{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. See https://github.com/RhetTbull/osxphotos
|
||||
/blob/master/examples/template_function.py for an
|
||||
example of how to implement a template function.
|
||||
|
||||
|
||||
|
||||
```
|
||||
@@ -2213,7 +2247,7 @@ e.g. If Photo is in `Album1` in `Folder1`:
|
||||
- `"{folder_album(>)}"` renders to `["Folder1>Album1"]`
|
||||
- `"{folder_album()}"` renders to `["Folder1Album1"]`
|
||||
|
||||
`[find|replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair.
|
||||
`[find,replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair.
|
||||
|
||||
`conditional`: optional conditional expression that is evaluated as boolean (True/False) for use with the `?bool_value` modifier. Conditional expressions take the form '` not operator value`' where `not` is an optional modifier that negates the `operator`. Note: the space before the conditional expression is required if you use a conditional expression. Valid comparison operators are:
|
||||
|
||||
@@ -2879,11 +2913,15 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|
||||
|{closeparens}|A close parentheses: ')'|
|
||||
|{openbracket}|An open bracket: '['|
|
||||
|{closebracket}|A close bracket: ']'|
|
||||
|{newline}|A newline: '\n'|
|
||||
|{lf}|A line feed: '\n', alias for {newline}|
|
||||
|{cr}|A carriage return: '\r'|
|
||||
|{crlf}|a carriage return + line feed: '\r\n'|
|
||||
|{album}|Album(s) photo is contained in|
|
||||
|{folder_album}|Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder|
|
||||
|{keyword}|Keyword(s) assigned to photo|
|
||||
|{person}|Person(s) / face(s) in a photo|
|
||||
|{label}|Image categorization label associated with a photo (Photos 5+ only)|
|
||||
|{label}|Image categorization label associated with a photo (Photos 5+ only). Labels are added automatically by Photos using machine learning algorithms to categorize images. These are not the same as {keyword} which refers to the user-defined keywords/tags applied in Photos.|
|
||||
|{label_normalized}|All lower case version of 'label' (Photos 5+ only)|
|
||||
|{comment}|Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)|
|
||||
|{exiftool}|Format: '{exiftool:GROUP:TAGNAME}'; use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. E.g. '{exiftool:EXIF:Make}' to get camera make, or {exiftool:IPTC:Keywords} to extract keywords. See https://exiftool.org/TagNames/ for list of valid tag names. You must specify group (e.g. EXIF, IPTC, etc) as used in `exiftool -G`. exiftool must be installed in the path to use this template.|
|
||||
@@ -2892,6 +2930,7 @@ The following template field substitutions are availabe for use with `PhotoInfo.
|
||||
|{searchinfo.venue}|Venues associated with a photo, e.g. name of restaurant; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|
||||
|{searchinfo.venue_type}|Venue types associated with a photo, e.g. 'Restaurant'; (Photos 5+ only, applied automatically by Photos' image categorization algorithms).|
|
||||
|{photo}|Provides direct access to the PhotoInfo object for the photo. Must be used in format '{photo.property}' where 'property' represents a PhotoInfo property. 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.|
|
||||
|{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. See https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py for an example of how to implement a template function.|
|
||||
<!-- OSXPHOTOS-TEMPLATE-TABLE:END -->
|
||||
|
||||
### Utility Functions
|
||||
@@ -3026,6 +3065,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
<td align="center"><a href="https://github.com/davidjroos"><img src="https://avatars.githubusercontent.com/u/15630844?v=4?s=75" width="75px;" alt=""/><br /><sub><b>davidjroos </b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=davidjroos" title="Documentation">📖</a></td>
|
||||
<td align="center"><a href="https://neilpa.me"><img src="https://avatars.githubusercontent.com/u/42419?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Neil Pankey</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=neilpa" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://aaronweb.net/"><img src="https://avatars.githubusercontent.com/u/604665?v=4?s=75" width="75px;" alt=""/><br /><sub><b>Aaron van Geffen</b></sub></a><br /><a href="https://github.com/RhetTbull/osxphotos/commits?author=AaronVanGeffen" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/ubrandes"><img src="https://avatars.githubusercontent.com/u/59647284?v=4?s=75" width="75px;" alt=""/><br /><sub><b>ubrandes </b></sub></a><br /><a href="#ideas-ubrandes" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
11
build.sh
Executable file
11
build.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
# script to help build osxphotos release
|
||||
# this is unique to my own dev setup
|
||||
|
||||
activate osxphotos
|
||||
rm -rf dist; rm -rf build
|
||||
python3 utils/update_readme.py
|
||||
(cd docsrc && make github && make pdf)
|
||||
python3 setup.py sdist bdist_wheel
|
||||
./make_cli_exe.sh
|
||||
@@ -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: 3a4409f9ef528dee2e4ce25074f0e795
|
||||
config: 041dede95604f7f8f5de01377fc1b414
|
||||
tags: 645f666f9bcd5a90fca523b33c5a78b7
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Overview: module code — osxphotos 0.42.00 documentation</title>
|
||||
<title>Overview: module code — osxphotos 0.42.4 documentation</title>
|
||||
<link rel="stylesheet" href="../_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="../_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="../" src="../_static/documentation_options.js"></script>
|
||||
|
||||
2
docs/_static/documentation_options.js
vendored
2
docs/_static/documentation_options.js
vendored
@@ -1,6 +1,6 @@
|
||||
var DOCUMENTATION_OPTIONS = {
|
||||
URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'),
|
||||
VERSION: '0.42.00',
|
||||
VERSION: '0.42.4',
|
||||
LANGUAGE: 'None',
|
||||
COLLAPSE_INDEX: false,
|
||||
BUILDER: 'html',
|
||||
|
||||
@@ -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) — osxphotos 0.42.00 documentation</title>
|
||||
<title>osxphotos command line interface (CLI) — osxphotos 0.42.4 documentation</title>
|
||||
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
|
||||
@@ -495,6 +495,18 @@ to modify this behavior.</p>
|
||||
<dd><p>Search for photos that are not in any albums.</p>
|
||||
</dd></dl>
|
||||
|
||||
<dl class="std option">
|
||||
<dt id="cmdoption-osxphotos-export-min-size">
|
||||
<code class="sig-name descname"><span class="pre">--min-size</span></code><code class="sig-prename descclassname"> <span class="pre"><SIZE></span></code><a class="headerlink" href="#cmdoption-osxphotos-export-min-size" title="Permalink to this definition">¶</a></dt>
|
||||
<dd><p>Search for photos with size >= SIZE bytes. The size evaluated is the photo’s original size (when imported to Photos). Size may be specified as integer bytes or using SI or NIST units. For example, the following are all valid and equivalent sizes: ‘1048576’ ‘1.048576MB’, ‘1 MiB’.</p>
|
||||
</dd></dl>
|
||||
|
||||
<dl class="std option">
|
||||
<dt id="cmdoption-osxphotos-export-max-size">
|
||||
<code class="sig-name descname"><span class="pre">--max-size</span></code><code class="sig-prename descclassname"> <span class="pre"><SIZE></span></code><a class="headerlink" href="#cmdoption-osxphotos-export-max-size" title="Permalink to this definition">¶</a></dt>
|
||||
<dd><p>Search for photos with size <= SIZE bytes. The size evaluated is the photo’s original size (when imported to Photos). Size may be specified as integer bytes or using SI or NIST units. For example, the following are all valid and equivalent sizes: ‘1048576’ ‘1.048576MB’, ‘1 MiB’.</p>
|
||||
</dd></dl>
|
||||
|
||||
<dl class="std option">
|
||||
<dt id="cmdoption-osxphotos-export-query-eval">
|
||||
<code class="sig-name descname"><span class="pre">--query-eval</span></code><code class="sig-prename descclassname"> <span class="pre"><CRITERIA></span></code><a class="headerlink" href="#cmdoption-osxphotos-export-query-eval" title="Permalink to this definition">¶</a></dt>
|
||||
@@ -1347,6 +1359,18 @@ if more than one option is provided, they are treated as “AND”
|
||||
<dd><p>Search for photos that are not in any albums.</p>
|
||||
</dd></dl>
|
||||
|
||||
<dl class="std option">
|
||||
<dt id="cmdoption-osxphotos-query-min-size">
|
||||
<code class="sig-name descname"><span class="pre">--min-size</span></code><code class="sig-prename descclassname"> <span class="pre"><SIZE></span></code><a class="headerlink" href="#cmdoption-osxphotos-query-min-size" title="Permalink to this definition">¶</a></dt>
|
||||
<dd><p>Search for photos with size >= SIZE bytes. The size evaluated is the photo’s original size (when imported to Photos). Size may be specified as integer bytes or using SI or NIST units. For example, the following are all valid and equivalent sizes: ‘1048576’ ‘1.048576MB’, ‘1 MiB’.</p>
|
||||
</dd></dl>
|
||||
|
||||
<dl class="std option">
|
||||
<dt id="cmdoption-osxphotos-query-max-size">
|
||||
<code class="sig-name descname"><span class="pre">--max-size</span></code><code class="sig-prename descclassname"> <span class="pre"><SIZE></span></code><a class="headerlink" href="#cmdoption-osxphotos-query-max-size" title="Permalink to this definition">¶</a></dt>
|
||||
<dd><p>Search for photos with size <= SIZE bytes. The size evaluated is the photo’s original size (when imported to Photos). Size may be specified as integer bytes or using SI or NIST units. For example, the following are all valid and equivalent sizes: ‘1048576’ ‘1.048576MB’, ‘1 MiB’.</p>
|
||||
</dd></dl>
|
||||
|
||||
<dl class="std option">
|
||||
<dt id="cmdoption-osxphotos-query-query-eval">
|
||||
<code class="sig-name descname"><span class="pre">--query-eval</span></code><code class="sig-prename descclassname"> <span class="pre"><CRITERIA></span></code><a class="headerlink" href="#cmdoption-osxphotos-query-query-eval" title="Permalink to this definition">¶</a></dt>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Index — osxphotos 0.42.00 documentation</title>
|
||||
<title>Index — osxphotos 0.42.4 documentation</title>
|
||||
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
|
||||
@@ -509,6 +509,24 @@
|
||||
|
||||
<ul>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-export-load-config">osxphotos-export command line option</a>
|
||||
</li>
|
||||
</ul></li>
|
||||
<li>
|
||||
--max-size <SIZE>
|
||||
|
||||
<ul>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-export-max-size">osxphotos-export command line option</a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-query-max-size">osxphotos-query command line option</a>
|
||||
</li>
|
||||
</ul></li>
|
||||
<li>
|
||||
--min-size <SIZE>
|
||||
|
||||
<ul>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-export-min-size">osxphotos-export command line option</a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-query-min-size">osxphotos-query command line option</a>
|
||||
</li>
|
||||
</ul></li>
|
||||
<li>
|
||||
@@ -547,6 +565,8 @@
|
||||
<li><a href="cli.html#cmdoption-osxphotos-query-no-description">osxphotos-query command line option</a>
|
||||
</li>
|
||||
</ul></li>
|
||||
</ul></td>
|
||||
<td style="width: 33%; vertical-align: top;"><ul>
|
||||
<li>
|
||||
--no-likes
|
||||
|
||||
@@ -556,8 +576,6 @@
|
||||
<li><a href="cli.html#cmdoption-osxphotos-query-no-likes">osxphotos-query command line option</a>
|
||||
</li>
|
||||
</ul></li>
|
||||
</ul></td>
|
||||
<td style="width: 33%; vertical-align: top;"><ul>
|
||||
<li>
|
||||
--no-place
|
||||
|
||||
@@ -1555,6 +1573,10 @@
|
||||
<li><a href="cli.html#cmdoption-osxphotos-export-live">--live</a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-export-load-config">--load-config <config file path></a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-export-max-size">--max-size <SIZE></a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-export-min-size">--min-size <SIZE></a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-export-missing">--missing</a>
|
||||
</li>
|
||||
@@ -1811,6 +1833,10 @@
|
||||
<li><a href="cli.html#cmdoption-osxphotos-query-label">--label <LABEL></a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-query-live">--live</a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-query-max-size">--max-size <SIZE></a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-query-min-size">--min-size <SIZE></a>
|
||||
</li>
|
||||
<li><a href="cli.html#cmdoption-osxphotos-query-missing">--missing</a>
|
||||
</li>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Welcome to osxphotos’s documentation! — osxphotos 0.42.00 documentation</title>
|
||||
<title>Welcome to osxphotos’s documentation! — osxphotos 0.42.4 documentation</title>
|
||||
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>osxphotos — osxphotos 0.42.00 documentation</title>
|
||||
<title>osxphotos — osxphotos 0.42.4 documentation</title>
|
||||
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
|
||||
|
||||
BIN
docs/objects.inv
BIN
docs/objects.inv
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>osxphotos package — osxphotos 0.42.00 documentation</title>
|
||||
<title>osxphotos package — osxphotos 0.42.4 documentation</title>
|
||||
<link rel="stylesheet" href="_static/pygments.css" type="text/css" />
|
||||
<link rel="stylesheet" href="_static/alabaster.css" type="text/css" />
|
||||
<script id="documentation_options" data-url_root="./" src="_static/documentation_options.js"></script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Search — osxphotos 0.42.00 documentation</title>
|
||||
<title>Search — osxphotos 0.42.4 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
30
examples/template_function.py
Normal file
30
examples/template_function.py
Normal file
@@ -0,0 +1,30 @@
|
||||
""" Example showing how to use a custom function for osxphotos {function} template
|
||||
Use: osxphotos export /path/to/export --filename "{function:/path/to/template_function.py::example}"
|
||||
|
||||
You may place more than one template function in a single file as each is called by name using the {function:file.py::function_name} format
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
from typing import List, Union
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
def example(photo: osxphotos.PhotoInfo, **kwargs) -> Union[List, str]:
|
||||
""" example function for {function} template; adds suffix of # if photo has adjustments and ! if photo is a favorite
|
||||
|
||||
Args:
|
||||
photo: osxphotos.PhotoInfo object
|
||||
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
|
||||
|
||||
Returns:
|
||||
str or list of str of values that should be substituted for the {function} template
|
||||
"""
|
||||
|
||||
filename = pathlib.Path(photo.original_filename).stem
|
||||
if photo.hasadjustments:
|
||||
filename += "#"
|
||||
if photo.favorite:
|
||||
filename += "!"
|
||||
|
||||
return filename
|
||||
@@ -3,6 +3,7 @@ from .photoinfo import PhotoInfo
|
||||
from .photosdb import PhotosDB
|
||||
from .photosdb._photosdb_process_comments import CommentInfo, LikeInfo
|
||||
from .phototemplate import PhotoTemplate
|
||||
from .queryoptions import QueryOptions
|
||||
from .utils import _debug, _get_logger, _set_debug
|
||||
|
||||
# TODO: Add test for imageTimeZoneOffsetSeconds = None
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
""" version info """
|
||||
|
||||
__version__ = "0.42.00"
|
||||
__version__ = "0.42.6"
|
||||
|
||||
530
osxphotos/cli.py
530
osxphotos/cli.py
@@ -11,6 +11,7 @@ import sys
|
||||
import time
|
||||
import unicodedata
|
||||
|
||||
import bitmath
|
||||
import click
|
||||
import osxmetadata
|
||||
import yaml
|
||||
@@ -50,6 +51,7 @@ 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 .queryoptions import QueryOptions
|
||||
from .utils import get_preferred_uti_extension
|
||||
|
||||
# global variable to control verbose output
|
||||
@@ -71,19 +73,6 @@ def verbose_(*args, **kwargs):
|
||||
click.echo(*styled_args, **kwargs)
|
||||
|
||||
|
||||
def normalize_unicode(value):
|
||||
""" normalize unicode data """
|
||||
if value is not None:
|
||||
if isinstance(value, tuple):
|
||||
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
|
||||
elif isinstance(value, str):
|
||||
return unicodedata.normalize(UNICODE_FORMAT, value)
|
||||
else:
|
||||
return value
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def get_photos_db(*db_options):
|
||||
"""Return path to photos db, select first non-None db_options
|
||||
If no db_options are non-None, try to find library to use in
|
||||
@@ -131,6 +120,26 @@ class DateTimeISO8601(click.ParamType):
|
||||
)
|
||||
|
||||
|
||||
class BitMathSize(click.ParamType):
|
||||
|
||||
name = "BITMATH"
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
try:
|
||||
value = bitmath.parse_string(value)
|
||||
except ValueError:
|
||||
# no units specified
|
||||
try:
|
||||
value = int(value)
|
||||
value = bitmath.Byte(value)
|
||||
except ValueError as e:
|
||||
self.fail(
|
||||
f"{value} must be specified as bytes or using SI/NIST units. "
|
||||
+ "For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class TimeISO8601(click.ParamType):
|
||||
|
||||
name = "TIME"
|
||||
@@ -202,7 +211,7 @@ def deleted_options(f):
|
||||
return f
|
||||
|
||||
|
||||
def query_options(f):
|
||||
def QUERY_OPTIONS(f):
|
||||
o = click.option
|
||||
options = [
|
||||
o(
|
||||
@@ -447,6 +456,24 @@ def query_options(f):
|
||||
is_flag=True,
|
||||
help="Search for photos that are not in any albums.",
|
||||
),
|
||||
o(
|
||||
"--min-size",
|
||||
metavar="SIZE",
|
||||
type=BitMathSize(),
|
||||
help="Search for photos with size >= SIZE bytes. "
|
||||
"The size evaluated is the photo's original size (when imported to Photos). "
|
||||
"Size may be specified as integer bytes or using SI or NIST units. "
|
||||
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
|
||||
),
|
||||
o(
|
||||
"--max-size",
|
||||
metavar="SIZE",
|
||||
type=BitMathSize(),
|
||||
help="Search for photos with size <= SIZE bytes. "
|
||||
"The size evaluated is the photo's original size (when imported to Photos). "
|
||||
"Size may be specified as integer bytes or using SI or NIST units. "
|
||||
"For example, the following are all valid and equivalent sizes: '1048576' '1.048576MB', '1 MiB'.",
|
||||
),
|
||||
o(
|
||||
"--query-eval",
|
||||
metavar="CRITERIA",
|
||||
@@ -480,7 +507,7 @@ def cli(ctx, db, json_, debug):
|
||||
@cli.command(cls=ExportCommand)
|
||||
@DB_OPTION
|
||||
@click.option("--verbose", "-V", "verbose", is_flag=True, help="Print verbose output.")
|
||||
@query_options
|
||||
@QUERY_OPTIONS
|
||||
@click.option(
|
||||
"--missing",
|
||||
is_flag=True,
|
||||
@@ -998,6 +1025,8 @@ def export(
|
||||
beta,
|
||||
in_album,
|
||||
not_in_album,
|
||||
min_size,
|
||||
max_size,
|
||||
query_eval,
|
||||
):
|
||||
"""Export photos from the Photos database.
|
||||
@@ -1146,6 +1175,8 @@ def export(
|
||||
only_new = cfg.only_new
|
||||
in_album = cfg.in_album
|
||||
not_in_album = cfg.not_in_album
|
||||
min_size = cfg.min_size
|
||||
max_size = cfg.max_size
|
||||
query_eval = cfg.query_eval
|
||||
|
||||
# config file might have changed verbose
|
||||
@@ -1295,11 +1326,11 @@ def export(
|
||||
if any([exiftool, exiftool_merge_keywords, exiftool_merge_persons]):
|
||||
verbose_(f"exiftool path: {exiftool_path}")
|
||||
|
||||
isphoto = ismovie = True # default searches for everything
|
||||
photos = movies = True # default searches for everything
|
||||
if only_movies:
|
||||
isphoto = False
|
||||
photos = False
|
||||
if only_photos:
|
||||
ismovie = False
|
||||
movies = False
|
||||
|
||||
# load UUIDs if necessary and append to any uuids passed with --uuid
|
||||
if uuid_from_file:
|
||||
@@ -1381,8 +1412,7 @@ def export(
|
||||
# enable beta features if requested
|
||||
photosdb._beta = beta
|
||||
|
||||
photos = _query(
|
||||
photosdb=photosdb,
|
||||
query_options = QueryOptions(
|
||||
keyword=keyword,
|
||||
person=person,
|
||||
album=album,
|
||||
@@ -1403,8 +1433,8 @@ def export(
|
||||
not_missing=None,
|
||||
shared=shared,
|
||||
not_shared=not_shared,
|
||||
isphoto=isphoto,
|
||||
ismovie=ismovie,
|
||||
photos=photos,
|
||||
movies=movies,
|
||||
uti=uti,
|
||||
burst=burst,
|
||||
not_burst=not_burst,
|
||||
@@ -1449,9 +1479,22 @@ def export(
|
||||
# skip missing bursts if using --download-missing by itself as AppleScript otherwise causes errors
|
||||
missing_bursts=(download_missing and use_photokit) or not download_missing,
|
||||
name=name,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
query_eval=query_eval,
|
||||
)
|
||||
|
||||
try:
|
||||
photos = photosdb.query(query_options)
|
||||
except ValueError as e:
|
||||
if "Invalid query_eval CRITERIA:" in str(e):
|
||||
msg = str(e).split(":")[1]
|
||||
raise click.BadOptionUsage(
|
||||
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(e)
|
||||
|
||||
if photos:
|
||||
if only_new:
|
||||
# ignore previously exported files
|
||||
@@ -1634,7 +1677,7 @@ def help(ctx, topic, **kw):
|
||||
@cli.command()
|
||||
@DB_OPTION
|
||||
@JSON_OPTION
|
||||
@query_options
|
||||
@QUERY_OPTIONS
|
||||
@deleted_options
|
||||
@click.option("--missing", is_flag=True, help="Search for photos missing from disk.")
|
||||
@click.option(
|
||||
@@ -1735,6 +1778,8 @@ def query(
|
||||
is_reference,
|
||||
in_album,
|
||||
not_in_album,
|
||||
min_size,
|
||||
max_size,
|
||||
query_eval,
|
||||
):
|
||||
"""Query the Photos database using 1 or more search options;
|
||||
@@ -1763,6 +1808,8 @@ def query(
|
||||
label,
|
||||
is_reference,
|
||||
query_eval,
|
||||
min_size,
|
||||
max_size,
|
||||
]
|
||||
exclusive = [
|
||||
(favorite, not_favorite),
|
||||
@@ -1798,11 +1845,11 @@ def query(
|
||||
return
|
||||
|
||||
# actually have something to query
|
||||
isphoto = ismovie = True # default searches for everything
|
||||
photos = movies = True # default searches for everything
|
||||
if only_movies:
|
||||
isphoto = False
|
||||
photos = False
|
||||
if only_photos:
|
||||
ismovie = False
|
||||
movies = False
|
||||
|
||||
# load UUIDs if necessary and append to any uuids passed with --uuid
|
||||
if uuid_from_file:
|
||||
@@ -1820,8 +1867,7 @@ def query(
|
||||
return
|
||||
|
||||
photosdb = osxphotos.PhotosDB(dbfile=db, verbose=verbose_)
|
||||
photos = _query(
|
||||
photosdb=photosdb,
|
||||
query_options = QueryOptions(
|
||||
keyword=keyword,
|
||||
person=person,
|
||||
album=album,
|
||||
@@ -1842,8 +1888,8 @@ def query(
|
||||
not_missing=not_missing,
|
||||
shared=shared,
|
||||
not_shared=not_shared,
|
||||
isphoto=isphoto,
|
||||
ismovie=ismovie,
|
||||
photos=photos,
|
||||
movies=movies,
|
||||
uti=uti,
|
||||
burst=burst,
|
||||
not_burst=not_burst,
|
||||
@@ -1885,9 +1931,22 @@ def query(
|
||||
in_album=in_album,
|
||||
not_in_album=not_in_album,
|
||||
name=name,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
query_eval=query_eval,
|
||||
)
|
||||
|
||||
try:
|
||||
photos = photosdb.query(query_options)
|
||||
except ValueError as e:
|
||||
if "Invalid query_eval CRITERIA:" in str(e):
|
||||
msg = str(e).split(":")[1]
|
||||
raise click.BadOptionUsage(
|
||||
"query_eval", f"Invalid query-eval CRITERIA: {msg}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(e)
|
||||
|
||||
# below needed for to make CliRunner work for testing
|
||||
cli_json = cli_obj.json if cli_obj is not None else None
|
||||
print_photo_info(photos, cli_json or json_)
|
||||
@@ -1997,409 +2056,6 @@ def print_photo_info(photos, json=False):
|
||||
csv_writer.writerow(row)
|
||||
|
||||
|
||||
def _query(
|
||||
photosdb,
|
||||
keyword=None,
|
||||
person=None,
|
||||
album=None,
|
||||
folder=None,
|
||||
uuid=None,
|
||||
title=None,
|
||||
no_title=None,
|
||||
description=None,
|
||||
no_description=None,
|
||||
ignore_case=None,
|
||||
edited=None,
|
||||
external_edit=None,
|
||||
favorite=None,
|
||||
not_favorite=None,
|
||||
hidden=None,
|
||||
not_hidden=None,
|
||||
missing=None,
|
||||
not_missing=None,
|
||||
shared=None,
|
||||
not_shared=None,
|
||||
isphoto=None,
|
||||
ismovie=None,
|
||||
uti=None,
|
||||
burst=None,
|
||||
not_burst=None,
|
||||
live=None,
|
||||
not_live=None,
|
||||
cloudasset=None,
|
||||
not_cloudasset=None,
|
||||
incloud=None,
|
||||
not_incloud=None,
|
||||
from_date=None,
|
||||
to_date=None,
|
||||
from_time=None,
|
||||
to_time=None,
|
||||
portrait=None,
|
||||
not_portrait=None,
|
||||
screenshot=None,
|
||||
not_screenshot=None,
|
||||
slow_mo=None,
|
||||
not_slow_mo=None,
|
||||
time_lapse=None,
|
||||
not_time_lapse=None,
|
||||
hdr=None,
|
||||
not_hdr=None,
|
||||
selfie=None,
|
||||
not_selfie=None,
|
||||
panorama=None,
|
||||
not_panorama=None,
|
||||
has_raw=None,
|
||||
place=None,
|
||||
no_place=None,
|
||||
label=None,
|
||||
deleted=False,
|
||||
deleted_only=False,
|
||||
has_comment=False,
|
||||
no_comment=False,
|
||||
has_likes=False,
|
||||
no_likes=False,
|
||||
is_reference=False,
|
||||
in_album=False,
|
||||
not_in_album=False,
|
||||
burst_photos=None,
|
||||
missing_bursts=None,
|
||||
name=None,
|
||||
query_eval=None,
|
||||
):
|
||||
"""Run a query against PhotosDB to extract the photos based on user supply criteria used by query and export commands
|
||||
|
||||
Args:
|
||||
photosdb: PhotosDB object
|
||||
"""
|
||||
|
||||
if deleted or deleted_only:
|
||||
photos = photosdb.photos(
|
||||
uuid=uuid,
|
||||
images=isphoto,
|
||||
movies=ismovie,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
intrash=True,
|
||||
)
|
||||
else:
|
||||
photos = []
|
||||
if not deleted_only:
|
||||
photos += photosdb.photos(
|
||||
uuid=uuid,
|
||||
images=isphoto,
|
||||
movies=ismovie,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
)
|
||||
|
||||
person = normalize_unicode(person)
|
||||
keyword = normalize_unicode(keyword)
|
||||
album = normalize_unicode(album)
|
||||
folder = normalize_unicode(folder)
|
||||
title = normalize_unicode(title)
|
||||
description = normalize_unicode(description)
|
||||
place = normalize_unicode(place)
|
||||
label = normalize_unicode(label)
|
||||
|
||||
if album:
|
||||
photos = get_photos_by_attribute(photos, "albums", album, ignore_case)
|
||||
|
||||
if keyword:
|
||||
photos = get_photos_by_attribute(photos, "keywords", keyword, ignore_case)
|
||||
|
||||
if person:
|
||||
photos = get_photos_by_attribute(photos, "persons", person, ignore_case)
|
||||
|
||||
if label:
|
||||
photos = get_photos_by_attribute(photos, "labels", label, ignore_case)
|
||||
|
||||
if folder:
|
||||
# search for photos in an album in folder
|
||||
# finds photos that have albums whose top level folder matches folder
|
||||
photo_list = []
|
||||
for f in folder:
|
||||
photo_list.extend(
|
||||
[
|
||||
p
|
||||
for p in photos
|
||||
if p.album_info
|
||||
and f in [a.folder_names[0] for a in p.album_info if a.folder_names]
|
||||
]
|
||||
)
|
||||
photos = photo_list
|
||||
|
||||
if title:
|
||||
# search title field for text
|
||||
# if more than one, find photos with all title values in title
|
||||
photo_list = []
|
||||
if ignore_case:
|
||||
# case-insensitive
|
||||
for t in title:
|
||||
t = t.lower()
|
||||
photo_list.extend(
|
||||
[p for p in photos if p.title and t in p.title.lower()]
|
||||
)
|
||||
else:
|
||||
for t in title:
|
||||
photo_list.extend([p for p in photos if p.title and t in p.title])
|
||||
photos = photo_list
|
||||
elif no_title:
|
||||
photos = [p for p in photos if not p.title]
|
||||
|
||||
if description:
|
||||
# search description field for text
|
||||
# if more than one, find photos with all description values in description
|
||||
photo_list = []
|
||||
if ignore_case:
|
||||
# case-insensitive
|
||||
for d in description:
|
||||
d = d.lower()
|
||||
photo_list.extend(
|
||||
[p for p in photos if p.description and d in p.description.lower()]
|
||||
)
|
||||
else:
|
||||
for d in description:
|
||||
photo_list.extend(
|
||||
[p for p in photos if p.description and d in p.description]
|
||||
)
|
||||
photos = photo_list
|
||||
elif no_description:
|
||||
photos = [p for p in photos if not p.description]
|
||||
|
||||
if place:
|
||||
# search place.names for text matching place
|
||||
# if more than one place, find photos with all place values in description
|
||||
if ignore_case:
|
||||
# case-insensitive
|
||||
for place_name in place:
|
||||
place_name = place_name.lower()
|
||||
photos = [
|
||||
p
|
||||
for p in photos
|
||||
if p.place
|
||||
and any(
|
||||
pname
|
||||
for pname in p.place.names
|
||||
if any(
|
||||
pvalue for pvalue in pname if place_name in pvalue.lower()
|
||||
)
|
||||
)
|
||||
]
|
||||
else:
|
||||
for place_name in place:
|
||||
photos = [
|
||||
p
|
||||
for p in photos
|
||||
if p.place
|
||||
and any(
|
||||
pname
|
||||
for pname in p.place.names
|
||||
if any(pvalue for pvalue in pname if place_name in pvalue)
|
||||
)
|
||||
]
|
||||
elif no_place:
|
||||
photos = [p for p in photos if not p.place]
|
||||
|
||||
if edited:
|
||||
photos = [p for p in photos if p.hasadjustments]
|
||||
|
||||
if external_edit:
|
||||
photos = [p for p in photos if p.external_edit]
|
||||
|
||||
if favorite:
|
||||
photos = [p for p in photos if p.favorite]
|
||||
elif not_favorite:
|
||||
photos = [p for p in photos if not p.favorite]
|
||||
|
||||
if hidden:
|
||||
photos = [p for p in photos if p.hidden]
|
||||
elif not_hidden:
|
||||
photos = [p for p in photos if not p.hidden]
|
||||
|
||||
if missing:
|
||||
photos = [p for p in photos if not p.path]
|
||||
elif not_missing:
|
||||
photos = [p for p in photos if p.path]
|
||||
|
||||
if shared:
|
||||
photos = [p for p in photos if p.shared]
|
||||
elif not_shared:
|
||||
photos = [p for p in photos if not p.shared]
|
||||
|
||||
if shared:
|
||||
photos = [p for p in photos if p.shared]
|
||||
elif not_shared:
|
||||
photos = [p for p in photos if not p.shared]
|
||||
|
||||
if uti:
|
||||
photos = [p for p in photos if uti in p.uti_original]
|
||||
|
||||
if burst:
|
||||
photos = [p for p in photos if p.burst]
|
||||
elif not_burst:
|
||||
photos = [p for p in photos if not p.burst]
|
||||
|
||||
if live:
|
||||
photos = [p for p in photos if p.live_photo]
|
||||
elif not_live:
|
||||
photos = [p for p in photos if not p.live_photo]
|
||||
|
||||
if portrait:
|
||||
photos = [p for p in photos if p.portrait]
|
||||
elif not_portrait:
|
||||
photos = [p for p in photos if not p.portrait]
|
||||
|
||||
if screenshot:
|
||||
photos = [p for p in photos if p.screenshot]
|
||||
elif not_screenshot:
|
||||
photos = [p for p in photos if not p.screenshot]
|
||||
|
||||
if slow_mo:
|
||||
photos = [p for p in photos if p.slow_mo]
|
||||
elif not_slow_mo:
|
||||
photos = [p for p in photos if not p.slow_mo]
|
||||
|
||||
if time_lapse:
|
||||
photos = [p for p in photos if p.time_lapse]
|
||||
elif not_time_lapse:
|
||||
photos = [p for p in photos if not p.time_lapse]
|
||||
|
||||
if hdr:
|
||||
photos = [p for p in photos if p.hdr]
|
||||
elif not_hdr:
|
||||
photos = [p for p in photos if not p.hdr]
|
||||
|
||||
if selfie:
|
||||
photos = [p for p in photos if p.selfie]
|
||||
elif not_selfie:
|
||||
photos = [p for p in photos if not p.selfie]
|
||||
|
||||
if panorama:
|
||||
photos = [p for p in photos if p.panorama]
|
||||
elif not_panorama:
|
||||
photos = [p for p in photos if not p.panorama]
|
||||
|
||||
if cloudasset:
|
||||
photos = [p for p in photos if p.iscloudasset]
|
||||
elif not_cloudasset:
|
||||
photos = [p for p in photos if not p.iscloudasset]
|
||||
|
||||
if incloud:
|
||||
photos = [p for p in photos if p.incloud]
|
||||
elif not_incloud:
|
||||
photos = [p for p in photos if not p.incloud]
|
||||
|
||||
if has_raw:
|
||||
photos = [p for p in photos if p.has_raw]
|
||||
|
||||
if has_comment:
|
||||
photos = [p for p in photos if p.comments]
|
||||
elif no_comment:
|
||||
photos = [p for p in photos if not p.comments]
|
||||
|
||||
if has_likes:
|
||||
photos = [p for p in photos if p.likes]
|
||||
elif no_likes:
|
||||
photos = [p for p in photos if not p.likes]
|
||||
|
||||
if is_reference:
|
||||
photos = [p for p in photos if p.isreference]
|
||||
|
||||
if in_album:
|
||||
photos = [p for p in photos if p.albums]
|
||||
elif not_in_album:
|
||||
photos = [p for p in photos if not p.albums]
|
||||
|
||||
if from_time:
|
||||
photos = [p for p in photos if p.date.time() >= from_time]
|
||||
|
||||
if to_time:
|
||||
photos = [p for p in photos if p.date.time() <= to_time]
|
||||
|
||||
if burst_photos:
|
||||
# add the burst_photos to the export set
|
||||
photos_burst = [p for p in photos if p.burst]
|
||||
for burst in photos_burst:
|
||||
if missing_bursts:
|
||||
# include burst photos that are missing
|
||||
photos.extend(burst.burst_photos)
|
||||
else:
|
||||
# don't include missing burst images (these can't be downloaded with AppleScript)
|
||||
photos.extend([p for p in burst.burst_photos if not p.ismissing])
|
||||
|
||||
# remove duplicates as each burst photo in the set that's selected would
|
||||
# result in the entire set being added above
|
||||
# can't use set() because PhotoInfo not hashable
|
||||
seen_uuids = {}
|
||||
for p in photos:
|
||||
if p.uuid in seen_uuids:
|
||||
continue
|
||||
seen_uuids[p.uuid] = p
|
||||
photos = list(seen_uuids.values())
|
||||
|
||||
if name:
|
||||
# search filename fields for text
|
||||
# if more than one, find photos with all title values in filename
|
||||
photo_list = []
|
||||
if ignore_case:
|
||||
# case-insensitive
|
||||
for n in name:
|
||||
n = n.lower()
|
||||
photo_list.extend(
|
||||
[
|
||||
p
|
||||
for p in photos
|
||||
if n in p.filename.lower() or n in p.original_filename.lower()
|
||||
]
|
||||
)
|
||||
else:
|
||||
for n in name:
|
||||
photo_list.extend(
|
||||
[p for p in photos if n in p.filename or n in p.original_filename]
|
||||
)
|
||||
photos = photo_list
|
||||
|
||||
if query_eval:
|
||||
for q in query_eval:
|
||||
query_string = f"[photo for photo in photos if {q}]"
|
||||
try:
|
||||
photos = eval(query_string)
|
||||
except Exception as e:
|
||||
raise click.BadOptionUsage(
|
||||
"query_eval", f"Invalid query-eval CRITERIA: {e}"
|
||||
)
|
||||
|
||||
return photos
|
||||
|
||||
|
||||
def get_photos_by_attribute(photos, attribute, values, ignore_case):
|
||||
"""Search for photos based on values being in PhotoInfo.attribute
|
||||
|
||||
Args:
|
||||
photos: a list of PhotoInfo objects
|
||||
attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc)
|
||||
values: list of values to search in property
|
||||
ignore_case: ignore case when searching
|
||||
|
||||
Returns:
|
||||
list of PhotoInfo objects matching search criteria
|
||||
"""
|
||||
photos_search = []
|
||||
if ignore_case:
|
||||
# case-insensitive
|
||||
for x in values:
|
||||
x = x.lower()
|
||||
photos_search.extend(
|
||||
p
|
||||
for p in photos
|
||||
if x in [attr.lower() for attr in getattr(p, attribute)]
|
||||
)
|
||||
else:
|
||||
for x in values:
|
||||
photos_search.extend(p for p in photos if x in getattr(p, attribute))
|
||||
return photos_search
|
||||
|
||||
|
||||
def export_photo(
|
||||
photo=None,
|
||||
dest=None,
|
||||
@@ -3307,7 +2963,7 @@ def write_finder_tags(
|
||||
|
||||
# filter out any template values that didn't match by looking for sentinel
|
||||
rendered_tags = [
|
||||
tag for tag in rendered_tags if _OSXPHOTOS_NONE_SENTINEL not in tag
|
||||
value.replace(_OSXPHOTOS_NONE_SENTINEL, "") for value in rendered_tags
|
||||
]
|
||||
tags.extend(rendered_tags)
|
||||
|
||||
@@ -3358,10 +3014,10 @@ def write_extended_attributes(photo, files, xattr_template, strip=False):
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
|
||||
# filter out any template values that didn't match by looking for sentinel
|
||||
rendered = [
|
||||
value for value in rendered if _OSXPHOTOS_NONE_SENTINEL not in value
|
||||
]
|
||||
rendered = [value.replace(_OSXPHOTOS_NONE_SENTINEL, "") for value in rendered]
|
||||
|
||||
try:
|
||||
attributes[xattr].extend(rendered)
|
||||
except KeyError:
|
||||
|
||||
@@ -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
|
||||
@@ -8,10 +8,14 @@ import os
|
||||
import os.path
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pprint import pformat
|
||||
from typing import List
|
||||
|
||||
import bitmath
|
||||
|
||||
from .._constants import (
|
||||
_DB_TABLE_NAMES,
|
||||
@@ -47,6 +51,7 @@ from ..utils import (
|
||||
noop,
|
||||
normalize_unicode,
|
||||
)
|
||||
from ..queryoptions import QueryOptions
|
||||
from .photosdb_utils import get_db_model_version, get_db_version
|
||||
|
||||
# TODO: Add test for imageTimeZoneOffsetSeconds = None
|
||||
@@ -2833,6 +2838,346 @@ class PhotosDB:
|
||||
pass
|
||||
return photos
|
||||
|
||||
def query(self, options: QueryOptions) -> List[PhotoInfo]:
|
||||
"""Run a query against PhotosDB to extract the photos based on user supplied options
|
||||
|
||||
Args:
|
||||
options: a QueryOptions instance
|
||||
"""
|
||||
|
||||
if options.deleted or options.deleted_only:
|
||||
photos = self.photos(
|
||||
uuid=options.uuid,
|
||||
images=options.photos,
|
||||
movies=options.movies,
|
||||
from_date=options.from_date,
|
||||
to_date=options.to_date,
|
||||
intrash=True,
|
||||
)
|
||||
else:
|
||||
photos = []
|
||||
|
||||
if not options.deleted_only:
|
||||
photos += self.photos(
|
||||
uuid=options.uuid,
|
||||
images=options.photos,
|
||||
movies=options.movies,
|
||||
from_date=options.from_date,
|
||||
to_date=options.to_date,
|
||||
)
|
||||
|
||||
person = normalize_unicode(options.person)
|
||||
keyword = normalize_unicode(options.keyword)
|
||||
album = normalize_unicode(options.album)
|
||||
folder = normalize_unicode(options.folder)
|
||||
title = normalize_unicode(options.title)
|
||||
description = normalize_unicode(options.description)
|
||||
place = normalize_unicode(options.place)
|
||||
label = normalize_unicode(options.label)
|
||||
name = normalize_unicode(options.name)
|
||||
|
||||
if album:
|
||||
photos = _get_photos_by_attribute(
|
||||
photos, "albums", album, options.ignore_case
|
||||
)
|
||||
|
||||
if keyword:
|
||||
photos = _get_photos_by_attribute(
|
||||
photos, "keywords", keyword, options.ignore_case
|
||||
)
|
||||
|
||||
if person:
|
||||
photos = _get_photos_by_attribute(
|
||||
photos, "persons", person, options.ignore_case
|
||||
)
|
||||
|
||||
if label:
|
||||
photos = _get_photos_by_attribute(
|
||||
photos, "labels", label, options.ignore_case
|
||||
)
|
||||
|
||||
if folder:
|
||||
# search for photos in an album in folder
|
||||
# finds photos that have albums whose top level folder matches folder
|
||||
photo_list = []
|
||||
for f in folder:
|
||||
photo_list.extend(
|
||||
[
|
||||
p
|
||||
for p in photos
|
||||
if p.album_info
|
||||
and f
|
||||
in [a.folder_names[0] for a in p.album_info if a.folder_names]
|
||||
]
|
||||
)
|
||||
photos = photo_list
|
||||
|
||||
if title:
|
||||
# search title field for text
|
||||
# if more than one, find photos with all title values in title
|
||||
photo_list = []
|
||||
if options.ignore_case:
|
||||
# case-insensitive
|
||||
for t in title:
|
||||
t = t.lower()
|
||||
photo_list.extend(
|
||||
[p for p in photos if p.title and t in p.title.lower()]
|
||||
)
|
||||
else:
|
||||
for t in title:
|
||||
photo_list.extend([p for p in photos if p.title and t in p.title])
|
||||
photos = photo_list
|
||||
elif options.no_title:
|
||||
photos = [p for p in photos if not p.title]
|
||||
|
||||
if description:
|
||||
# search description field for text
|
||||
# if more than one, find photos with all description values in description
|
||||
photo_list = []
|
||||
if options.ignore_case:
|
||||
# case-insensitive
|
||||
for d in description:
|
||||
d = d.lower()
|
||||
photo_list.extend(
|
||||
[
|
||||
p
|
||||
for p in photos
|
||||
if p.description and d in p.description.lower()
|
||||
]
|
||||
)
|
||||
else:
|
||||
for d in description:
|
||||
photo_list.extend(
|
||||
[p for p in photos if p.description and d in p.description]
|
||||
)
|
||||
photos = photo_list
|
||||
elif options.no_description:
|
||||
photos = [p for p in photos if not p.description]
|
||||
|
||||
if place:
|
||||
# search place.names for text matching place
|
||||
# if more than one place, find photos with all place values in description
|
||||
if options.ignore_case:
|
||||
# case-insensitive
|
||||
for place_name in place:
|
||||
place_name = place_name.lower()
|
||||
photos = [
|
||||
p
|
||||
for p in photos
|
||||
if p.place
|
||||
and any(
|
||||
pname
|
||||
for pname in p.place.names
|
||||
if any(
|
||||
pvalue
|
||||
for pvalue in pname
|
||||
if place_name in pvalue.lower()
|
||||
)
|
||||
)
|
||||
]
|
||||
else:
|
||||
for place_name in place:
|
||||
photos = [
|
||||
p
|
||||
for p in photos
|
||||
if p.place
|
||||
and any(
|
||||
pname
|
||||
for pname in p.place.names
|
||||
if any(pvalue for pvalue in pname if place_name in pvalue)
|
||||
)
|
||||
]
|
||||
elif options.no_place:
|
||||
photos = [p for p in photos if not p.place]
|
||||
|
||||
if options.edited:
|
||||
photos = [p for p in photos if p.hasadjustments]
|
||||
|
||||
if options.external_edit:
|
||||
photos = [p for p in photos if p.external_edit]
|
||||
|
||||
if options.favorite:
|
||||
photos = [p for p in photos if p.favorite]
|
||||
elif options.not_favorite:
|
||||
photos = [p for p in photos if not p.favorite]
|
||||
|
||||
if options.hidden:
|
||||
photos = [p for p in photos if p.hidden]
|
||||
elif options.not_hidden:
|
||||
photos = [p for p in photos if not p.hidden]
|
||||
|
||||
if options.missing:
|
||||
photos = [p for p in photos if not p.path]
|
||||
elif options.not_missing:
|
||||
photos = [p for p in photos if p.path]
|
||||
|
||||
if options.shared:
|
||||
photos = [p for p in photos if p.shared]
|
||||
elif options.not_shared:
|
||||
photos = [p for p in photos if not p.shared]
|
||||
|
||||
if options.shared:
|
||||
photos = [p for p in photos if p.shared]
|
||||
elif options.not_shared:
|
||||
photos = [p for p in photos if not p.shared]
|
||||
|
||||
if options.uti:
|
||||
photos = [p for p in photos if options.uti in p.uti_original]
|
||||
|
||||
if options.burst:
|
||||
photos = [p for p in photos if p.burst]
|
||||
elif options.not_burst:
|
||||
photos = [p for p in photos if not p.burst]
|
||||
|
||||
if options.live:
|
||||
photos = [p for p in photos if p.live_photo]
|
||||
elif options.not_live:
|
||||
photos = [p for p in photos if not p.live_photo]
|
||||
|
||||
if options.portrait:
|
||||
photos = [p for p in photos if p.portrait]
|
||||
elif options.not_portrait:
|
||||
photos = [p for p in photos if not p.portrait]
|
||||
|
||||
if options.screenshot:
|
||||
photos = [p for p in photos if p.screenshot]
|
||||
elif options.not_screenshot:
|
||||
photos = [p for p in photos if not p.screenshot]
|
||||
|
||||
if options.slow_mo:
|
||||
photos = [p for p in photos if p.slow_mo]
|
||||
elif options.not_slow_mo:
|
||||
photos = [p for p in photos if not p.slow_mo]
|
||||
|
||||
if options.time_lapse:
|
||||
photos = [p for p in photos if p.time_lapse]
|
||||
elif options.not_time_lapse:
|
||||
photos = [p for p in photos if not p.time_lapse]
|
||||
|
||||
if options.hdr:
|
||||
photos = [p for p in photos if p.hdr]
|
||||
elif options.not_hdr:
|
||||
photos = [p for p in photos if not p.hdr]
|
||||
|
||||
if options.selfie:
|
||||
photos = [p for p in photos if p.selfie]
|
||||
elif options.not_selfie:
|
||||
photos = [p for p in photos if not p.selfie]
|
||||
|
||||
if options.panorama:
|
||||
photos = [p for p in photos if p.panorama]
|
||||
elif options.not_panorama:
|
||||
photos = [p for p in photos if not p.panorama]
|
||||
|
||||
if options.cloudasset:
|
||||
photos = [p for p in photos if p.iscloudasset]
|
||||
elif options.not_cloudasset:
|
||||
photos = [p for p in photos if not p.iscloudasset]
|
||||
|
||||
if options.incloud:
|
||||
photos = [p for p in photos if p.incloud]
|
||||
elif options.not_incloud:
|
||||
photos = [p for p in photos if not p.incloud]
|
||||
|
||||
if options.has_raw:
|
||||
photos = [p for p in photos if p.has_raw]
|
||||
|
||||
if options.has_comment:
|
||||
photos = [p for p in photos if p.comments]
|
||||
elif options.no_comment:
|
||||
photos = [p for p in photos if not p.comments]
|
||||
|
||||
if options.has_likes:
|
||||
photos = [p for p in photos if p.likes]
|
||||
elif options.no_likes:
|
||||
photos = [p for p in photos if not p.likes]
|
||||
|
||||
if options.is_reference:
|
||||
photos = [p for p in photos if p.isreference]
|
||||
|
||||
if options.in_album:
|
||||
photos = [p for p in photos if p.albums]
|
||||
elif options.not_in_album:
|
||||
photos = [p for p in photos if not p.albums]
|
||||
|
||||
if options.from_time:
|
||||
photos = [p for p in photos if p.date.time() >= options.from_time]
|
||||
|
||||
if options.to_time:
|
||||
photos = [p for p in photos if p.date.time() <= options.to_time]
|
||||
|
||||
if options.burst_photos:
|
||||
# add the burst_photos to the export set
|
||||
photos_burst = [p for p in photos if p.burst]
|
||||
for burst in photos_burst:
|
||||
if options.missing_bursts:
|
||||
# include burst photos that are missing
|
||||
photos.extend(burst.burst_photos)
|
||||
else:
|
||||
# don't include missing burst images (these can't be downloaded with AppleScript)
|
||||
photos.extend([p for p in burst.burst_photos if not p.ismissing])
|
||||
|
||||
# remove duplicates as each burst photo in the set that's selected would
|
||||
# result in the entire set being added above
|
||||
# can't use set() because PhotoInfo not hashable
|
||||
seen_uuids = {}
|
||||
for p in photos:
|
||||
if p.uuid in seen_uuids:
|
||||
continue
|
||||
seen_uuids[p.uuid] = p
|
||||
photos = list(seen_uuids.values())
|
||||
|
||||
if name:
|
||||
# search filename fields for text
|
||||
# if more than one, find photos with all title values in filename
|
||||
photo_list = []
|
||||
if options.ignore_case:
|
||||
# case-insensitive
|
||||
for n in name:
|
||||
n = n.lower()
|
||||
photo_list.extend(
|
||||
[
|
||||
p
|
||||
for p in photos
|
||||
if n in p.filename.lower()
|
||||
or n in p.original_filename.lower()
|
||||
]
|
||||
)
|
||||
else:
|
||||
for n in name:
|
||||
photo_list.extend(
|
||||
[
|
||||
p
|
||||
for p in photos
|
||||
if n in p.filename or n in p.original_filename
|
||||
]
|
||||
)
|
||||
photos = photo_list
|
||||
|
||||
if options.min_size:
|
||||
photos = [
|
||||
p
|
||||
for p in photos
|
||||
if bitmath.Byte(p.original_filesize) >= options.min_size
|
||||
]
|
||||
|
||||
if options.max_size:
|
||||
photos = [
|
||||
p
|
||||
for p in photos
|
||||
if bitmath.Byte(p.original_filesize) <= options.max_size
|
||||
]
|
||||
|
||||
if options.query_eval:
|
||||
for q in options.query_eval:
|
||||
query_string = f"[photo for photo in photos if {q}]"
|
||||
try:
|
||||
photos = eval(query_string)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid query_eval CRITERIA: {e}")
|
||||
|
||||
return photos
|
||||
|
||||
def __repr__(self):
|
||||
return f"osxphotos.{self.__class__.__name__}(dbfile='{self.db_path}')"
|
||||
|
||||
@@ -2848,3 +3193,32 @@ class PhotosDB:
|
||||
Includes recently deleted photos and non-selected burst images
|
||||
"""
|
||||
return len(self._dbphotos)
|
||||
|
||||
|
||||
def _get_photos_by_attribute(photos, attribute, values, ignore_case):
|
||||
"""Search for photos based on values being in PhotoInfo.attribute
|
||||
|
||||
Args:
|
||||
photos: a list of PhotoInfo objects
|
||||
attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc)
|
||||
values: list of values to search in property
|
||||
ignore_case: ignore case when searching
|
||||
|
||||
Returns:
|
||||
list of PhotoInfo objects matching search criteria
|
||||
"""
|
||||
photos_search = []
|
||||
if ignore_case:
|
||||
# case-insensitive
|
||||
for x in values:
|
||||
x = x.lower()
|
||||
photos_search.extend(
|
||||
p
|
||||
for p in photos
|
||||
if x in [attr.lower() for attr in getattr(p, attribute)]
|
||||
)
|
||||
else:
|
||||
for x in values:
|
||||
photos_search.extend(p for p in photos if x in getattr(p, attribute))
|
||||
return photos_search
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ e.g. If Photo is in `Album1` in `Folder1`:
|
||||
- `"{folder_album(>)}"` renders to `["Folder1>Album1"]`
|
||||
- `"{folder_album()}"` renders to `["Folder1Album1"]`
|
||||
|
||||
`[find|replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair.
|
||||
`[find,replace]`: optional text replacement to perform on rendered template value. For example, to replace "/" in an album name, you could use the template `"{album[/,-]}"`. Multiple replacements can be made by appending "|" and adding another find|replace pair. e.g. to replace both "/" and ":" in album name: `"{album[/,-|:,-]}"`. find/replace pairs are not limited to single characters. The "|" character cannot be used in a find/replace pair.
|
||||
|
||||
`conditional`: optional conditional expression that is evaluated as boolean (True/False) for use with the `?bool_value` modifier. Conditional expressions take the form '` not operator value`' where `not` is an optional modifier that negates the `operator`. Note: the space before the conditional expression is required if you use a conditional expression. Valid comparison operators are:
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from ._constants import _UNKNOWN_PERSON
|
||||
from .datetime_formatter import DateTimeFormatter
|
||||
from .exiftool import ExifTool
|
||||
from .path_utils import sanitize_dirname, sanitize_filename, sanitize_pathpart
|
||||
from .utils import load_function
|
||||
|
||||
# ensure locale set to user's locale
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
@@ -128,6 +129,10 @@ TEMPLATE_SUBSTITUTIONS = {
|
||||
"{closeparens}": "A close parentheses: ')'",
|
||||
"{openbracket}": "An open bracket: '['",
|
||||
"{closebracket}": "A close bracket: ']'",
|
||||
"{newline}": r"A newline: '\n'",
|
||||
"{lf}": r"A line feed: '\n', alias for {newline}",
|
||||
"{cr}": r"A carriage return: '\r'",
|
||||
"{crlf}": r"a carriage return + line feed: '\r\n'",
|
||||
}
|
||||
|
||||
# Permitted multi-value substitutions (each of these returns None or 1 or more values)
|
||||
@@ -136,7 +141,9 @@ TEMPLATE_SUBSTITUTIONS_MULTI_VALUED = {
|
||||
"{folder_album}": "Folder path + album photo is contained in. e.g. 'Folder/Subfolder/Album' or just 'Album' if no enclosing folder",
|
||||
"{keyword}": "Keyword(s) assigned to photo",
|
||||
"{person}": "Person(s) / face(s) in a photo",
|
||||
"{label}": "Image categorization label associated with a photo (Photos 5+ only)",
|
||||
"{label}": "Image categorization label associated with a photo (Photos 5+ only). "
|
||||
"Labels are added automatically by Photos using machine learning algorithms to categorize images. "
|
||||
"These are not the same as {keyword} which refers to the user-defined keywords/tags applied in Photos.",
|
||||
"{label_normalized}": "All lower case version of 'label' (Photos 5+ only)",
|
||||
"{comment}": "Comment(s) on shared Photos; format is 'Person name: comment text' (Photos 5+ only)",
|
||||
"{exiftool}": "Format: '{exiftool:GROUP:TAGNAME}'; use exiftool (https://exiftool.org) to extract metadata, in form GROUP:TAGNAME, from image. "
|
||||
@@ -152,6 +159,10 @@ 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.",
|
||||
"{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. "
|
||||
+ "See https://github.com/RhetTbull/osxphotos/blob/master/examples/template_function.py for an example of how to implement a template function.",
|
||||
}
|
||||
|
||||
FILTER_VALUES = {
|
||||
@@ -193,6 +204,10 @@ PUNCTUATION = {
|
||||
"openbracket": "[",
|
||||
"closebracket": "]",
|
||||
"questionmark": "?",
|
||||
"newline": "\n",
|
||||
"lf": "\n",
|
||||
"cr": "\r",
|
||||
"crlf": "\r\n",
|
||||
}
|
||||
|
||||
|
||||
@@ -463,6 +478,14 @@ class PhotoTemplate:
|
||||
vals = self.get_template_value_exiftool(
|
||||
subfield, filename=filename, dirname=dirname
|
||||
)
|
||||
elif field == "function":
|
||||
if subfield is None:
|
||||
raise ValueError(
|
||||
"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
|
||||
)
|
||||
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
|
||||
@@ -1064,6 +1087,39 @@ class PhotoTemplate:
|
||||
|
||||
return values
|
||||
|
||||
def get_template_value_function(self, subfield, filename=None, dirname=None):
|
||||
"""Get template value from external function """
|
||||
|
||||
if "::" not in subfield:
|
||||
raise ValueError(
|
||||
f"SyntaxError: could not parse function name from '{subfield}'"
|
||||
)
|
||||
|
||||
filename, funcname = subfield.split("::")
|
||||
|
||||
print(filename, funcname)
|
||||
|
||||
if not pathlib.Path(filename).is_file():
|
||||
raise ValueError(f"'{filename}' does not appear to be a file")
|
||||
|
||||
template_func = load_function(filename, funcname)
|
||||
values = template_func(self.photo)
|
||||
|
||||
if not isinstance(values, (str, list)):
|
||||
raise TypeError(
|
||||
f"Invalid return type for function {funcname}: expected str or list"
|
||||
)
|
||||
if type(values) == str:
|
||||
values = [values]
|
||||
|
||||
# sanitize directory names if needed
|
||||
if filename:
|
||||
values = [sanitize_pathpart(value) for value in values]
|
||||
elif dirname:
|
||||
values = [sanitize_dirname(value) for value in values]
|
||||
|
||||
return values
|
||||
|
||||
def get_photo_video_type(self, default):
|
||||
""" return media type, e.g. photo or video """
|
||||
default_dict = parse_default_kv(default, PHOTO_VIDEO_TYPE_DEFAULTS)
|
||||
|
||||
@@ -51,6 +51,10 @@ Field:
|
||||
FIELD_WORD+
|
||||
;
|
||||
|
||||
FIELD_WORD:
|
||||
/[\.\w]+/
|
||||
;
|
||||
|
||||
SubField:
|
||||
(
|
||||
":"-
|
||||
@@ -58,12 +62,8 @@ SubField:
|
||||
)?
|
||||
;
|
||||
|
||||
FIELD_WORD:
|
||||
/[\.\w]+/
|
||||
;
|
||||
|
||||
SUBFIELD_WORD:
|
||||
/[\.\w:]+/
|
||||
/[\.\w:\/]+/
|
||||
;
|
||||
|
||||
Filter:
|
||||
|
||||
82
osxphotos/queryoptions.py
Normal file
82
osxphotos/queryoptions.py
Normal file
@@ -0,0 +1,82 @@
|
||||
""" QueryOptions class for PhotosDB.query """
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Iterable
|
||||
import datetime
|
||||
import bitmath
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryOptions:
|
||||
|
||||
keyword: Optional[Iterable[str]] = None
|
||||
person: Optional[Iterable[str]] = None
|
||||
album: Optional[Iterable[str]] = None
|
||||
folder: Optional[Iterable[str]] = None
|
||||
uuid: Optional[Iterable[str]] = None
|
||||
title: Optional[Iterable[str]] = None
|
||||
no_title: Optional[bool] = None
|
||||
description: Optional[Iterable[str]] = None
|
||||
no_description: Optional[bool] = None
|
||||
ignore_case: Optional[bool] = None
|
||||
edited: Optional[bool] = None
|
||||
external_edit: Optional[bool] = None
|
||||
favorite: Optional[bool] = None
|
||||
not_favorite: Optional[bool] = None
|
||||
hidden: Optional[bool] = None
|
||||
not_hidden: Optional[bool] = None
|
||||
missing: Optional[bool] = None
|
||||
not_missing: Optional[bool] = None
|
||||
shared: Optional[bool] = None
|
||||
not_shared: Optional[bool] = None
|
||||
photos: Optional[bool] = True
|
||||
movies: Optional[bool] = True
|
||||
uti: Optional[Iterable[str]] = None
|
||||
burst: Optional[bool] = None
|
||||
not_burst: Optional[bool] = None
|
||||
live: Optional[bool] = None
|
||||
not_live: Optional[bool] = None
|
||||
cloudasset: Optional[bool] = None
|
||||
not_cloudasset: Optional[bool] = None
|
||||
incloud: Optional[bool] = None
|
||||
not_incloud: Optional[bool] = None
|
||||
from_date: Optional[datetime.datetime] = None
|
||||
to_date: Optional[datetime.datetime] = None
|
||||
from_time: Optional[datetime.time] = None
|
||||
to_time: Optional[datetime.time] = None
|
||||
portrait: Optional[bool] = None
|
||||
not_portrait: Optional[bool] = None
|
||||
screenshot: Optional[bool] = None
|
||||
not_screenshot: Optional[bool] = None
|
||||
slow_mo: Optional[bool] = None
|
||||
not_slow_mo: Optional[bool] = None
|
||||
time_lapse: Optional[bool] = None
|
||||
not_time_lapse: Optional[bool] = None
|
||||
hdr: Optional[bool] = None
|
||||
not_hdr: Optional[bool] = None
|
||||
selfie: Optional[bool] = None
|
||||
not_selfie: Optional[bool] = None
|
||||
panorama: Optional[bool] = None
|
||||
not_panorama: Optional[bool] = None
|
||||
has_raw: Optional[bool] = None
|
||||
place: Optional[Iterable[str]] = None
|
||||
no_place: Optional[bool] = None
|
||||
label: Optional[Iterable[str]] = None
|
||||
deleted: Optional[bool] = None
|
||||
deleted_only: Optional[bool] = None
|
||||
has_comment: Optional[bool] = None
|
||||
no_comment: Optional[bool] = None
|
||||
has_likes: Optional[bool] = None
|
||||
no_likes: Optional[bool] = None
|
||||
is_reference: Optional[bool] = None
|
||||
in_album: Optional[bool] = None
|
||||
not_in_album: Optional[bool] = None
|
||||
burst_photos: Optional[bool] = None
|
||||
missing_bursts: Optional[bool] = None
|
||||
name: Optional[Iterable[str]] = None
|
||||
min_size: Optional[bitmath.Byte] = None
|
||||
max_size: Optional[bitmath.Byte] = None
|
||||
query_eval: Optional[Iterable[str]] = None
|
||||
|
||||
def asdict(self):
|
||||
return asdict(self)
|
||||
@@ -1,5 +1,8 @@
|
||||
""" Utility functions used in osxphotos """
|
||||
|
||||
import fnmatch
|
||||
import glob
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
@@ -13,6 +16,7 @@ import sys
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
from plistlib import load as plistload
|
||||
from typing import Callable
|
||||
|
||||
import CoreFoundation
|
||||
import CoreServices
|
||||
@@ -369,13 +373,16 @@ def _db_is_locked(dbname):
|
||||
|
||||
def normalize_unicode(value):
|
||||
""" normalize unicode data """
|
||||
if value is None:
|
||||
if value is not None:
|
||||
if isinstance(value, (tuple, list)):
|
||||
return tuple(unicodedata.normalize(UNICODE_FORMAT, v) for v in value)
|
||||
elif isinstance(value, str):
|
||||
return unicodedata.normalize(UNICODE_FORMAT, value)
|
||||
else:
|
||||
return value
|
||||
else:
|
||||
return None
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("value must be str")
|
||||
return unicodedata.normalize(UNICODE_FORMAT, value)
|
||||
|
||||
|
||||
def increment_filename(filepath):
|
||||
""" Return filename (1).ext, etc if filename.ext exists
|
||||
@@ -401,3 +408,28 @@ def increment_filename(filepath):
|
||||
count += 1
|
||||
dest = dest.parent / f"{dest_new}{dest.suffix}"
|
||||
return str(dest)
|
||||
|
||||
|
||||
def load_function(pyfile: str, function_name: str) -> Callable:
|
||||
""" Load function_name from python file pyfile """
|
||||
module_file = pathlib.Path(pyfile)
|
||||
if not module_file.is_file():
|
||||
raise FileNotFoundError(f"module {pyfile} does not appear to exist")
|
||||
|
||||
module_dir = module_file.parent or pathlib.Path(os.getcwd())
|
||||
module_name = module_file.stem
|
||||
|
||||
# store old sys.path and ensure module_dir at beginning of path
|
||||
syspath = sys.path
|
||||
sys.path = [str(module_dir)] + syspath
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
try:
|
||||
func = getattr(module, function_name)
|
||||
except AttributeError:
|
||||
raise ValueError(f"'{function_name}' not found in module '{module_name}'")
|
||||
finally:
|
||||
# restore sys.path
|
||||
sys.path = syspath
|
||||
|
||||
return func
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
bpylist2==3.0.2
|
||||
certifi==2020.4.5.1
|
||||
|
||||
1
setup.py
1
setup.py
@@ -86,6 +86,7 @@ setup(
|
||||
"osxmetadata>=0.99.13",
|
||||
"textx==2.3.0",
|
||||
"rich>=9.11.1",
|
||||
"bitmath==1.3.3.1",
|
||||
],
|
||||
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
|
||||
include_package_data=True,
|
||||
|
||||
File diff suppressed because one or more lines are too long
20
tests/template_function.py
Normal file
20
tests/template_function.py
Normal file
@@ -0,0 +1,20 @@
|
||||
""" Example showing how to use a custom function for osxphotos {function} template """
|
||||
|
||||
import pathlib
|
||||
from typing import List, Union
|
||||
|
||||
import osxphotos
|
||||
|
||||
|
||||
def foo(photo: osxphotos.PhotoInfo, **kwargs) -> Union[List, str]:
|
||||
""" example function for {function} template
|
||||
|
||||
Args:
|
||||
photo: osxphotos.PhotoInfo object
|
||||
**kwargs: not currently used, placeholder to keep functions compatible with possible changes to {function}
|
||||
|
||||
Returns:
|
||||
str or list of str of values that should be substituted for the {function} template
|
||||
"""
|
||||
|
||||
return photo.original_filename + "-FOO"
|
||||
@@ -462,7 +462,17 @@ CLI_FINDER_TAGS = {
|
||||
"XMP:Description": "Girl holding pumpkin",
|
||||
"XMP:PersonInImage": "Katie",
|
||||
"XMP:Subject": "Kids",
|
||||
}
|
||||
},
|
||||
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": {
|
||||
"File:FileName": "wedding.jpg",
|
||||
"IPTC:Keywords": ["Maria", "wedding"],
|
||||
"XMP:TagsList": ["Maria", "wedding"],
|
||||
"XMP:Title": None,
|
||||
"EXIF:ImageDescription": "Bride Wedding day",
|
||||
"XMP:Description": "Bride Wedding day",
|
||||
"XMP:PersonInImage": "Maria",
|
||||
"XMP:Subject": ["Maria", "wedding"],
|
||||
},
|
||||
}
|
||||
|
||||
LABELS_JSON = {
|
||||
@@ -5330,7 +5340,7 @@ def test_export_finder_tag_template_multiple():
|
||||
keywords = [keywords] if type(keywords) != list else keywords
|
||||
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
|
||||
persons = [persons] if type(persons) != list else persons
|
||||
expected = [Tag(x) for x in keywords + persons]
|
||||
expected = [Tag(x) for x in set(keywords + persons)]
|
||||
assert sorted(md.tags) == sorted(expected)
|
||||
|
||||
|
||||
@@ -5368,7 +5378,42 @@ def test_export_finder_tag_template_keywords():
|
||||
keywords = [keywords] if type(keywords) != list else keywords
|
||||
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
|
||||
persons = [persons] if type(persons) != list else persons
|
||||
expected = [Tag(x) for x in keywords + persons]
|
||||
expected = [Tag(x) for x in set(keywords + persons)]
|
||||
assert sorted(md.tags) == sorted(expected)
|
||||
|
||||
|
||||
def test_export_finder_tag_template_multi_field():
|
||||
""" test --finder-tag-template with multiple fields (issue #422) """
|
||||
import glob
|
||||
import os
|
||||
import os.path
|
||||
|
||||
from osxmetadata import OSXMetaData, Tag
|
||||
from osxphotos.cli import export
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
for uuid in CLI_FINDER_TAGS:
|
||||
result = runner.invoke(
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"-V",
|
||||
"--finder-tag-template",
|
||||
"{title};{descr}",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
|
||||
title = CLI_FINDER_TAGS[uuid]["XMP:Title"] or ""
|
||||
descr = CLI_FINDER_TAGS[uuid]["XMP:Description"] or ""
|
||||
expected = [Tag(f"{title};{descr}")]
|
||||
assert sorted(md.tags) == sorted(expected)
|
||||
|
||||
|
||||
@@ -5397,7 +5442,7 @@ def test_export_xattr_template():
|
||||
"{person}",
|
||||
"--xattr-template",
|
||||
"comment",
|
||||
"{title}",
|
||||
"{title};{descr}",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
],
|
||||
@@ -5408,7 +5453,9 @@ def test_export_xattr_template():
|
||||
expected = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
|
||||
expected = [expected] if type(expected) != list else expected
|
||||
assert sorted(md.keywords) == sorted(expected)
|
||||
assert md.comment == CLI_FINDER_TAGS[uuid]["XMP:Title"]
|
||||
title = CLI_FINDER_TAGS[uuid]["XMP:Title"] or ""
|
||||
descr = CLI_FINDER_TAGS[uuid]["XMP:Description"] or ""
|
||||
assert md.comment == f"{title};{descr}"
|
||||
|
||||
# run again with --update, should skip writing extended attributes
|
||||
result = runner.invoke(
|
||||
@@ -5422,7 +5469,7 @@ def test_export_xattr_template():
|
||||
"{person}",
|
||||
"--xattr-template",
|
||||
"comment",
|
||||
"{title}",
|
||||
"{title};{descr}",
|
||||
"--uuid",
|
||||
f"{uuid}",
|
||||
"--update",
|
||||
@@ -5768,6 +5815,7 @@ def test_export_name():
|
||||
files = glob.glob("*")
|
||||
assert len(files) == 1
|
||||
|
||||
|
||||
def test_query_eval():
|
||||
""" test export --query-eval """
|
||||
import glob
|
||||
@@ -5778,7 +5826,14 @@ def test_query_eval():
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--query-eval", "'DSC03584' in photo.original_filename"]
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"-V",
|
||||
"--query-eval",
|
||||
"'DSC03584' in photo.original_filename",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
files = glob.glob("*")
|
||||
@@ -5795,7 +5850,146 @@ def test_bad_query_eval():
|
||||
# pylint: disable=not-context-manager
|
||||
with runner.isolated_filesystem():
|
||||
result = runner.invoke(
|
||||
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--query-eval", "'DSC03584' in photo.originalfilename"]
|
||||
export,
|
||||
[
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
".",
|
||||
"-V",
|
||||
"--query-eval",
|
||||
"'DSC03584' in photo.originalfilename",
|
||||
],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Error: Invalid query-eval CRITERIA" in result.output
|
||||
assert "Error: Invalid query-eval CRITERIA" in result.output
|
||||
|
||||
|
||||
def test_query_min_size_1():
|
||||
""" test query --min-size """
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.cli import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "10MB"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
|
||||
assert len(json_got) == 2
|
||||
|
||||
|
||||
def test_query_min_size_2():
|
||||
""" test query --min-size """
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.cli import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
"--min-size",
|
||||
"10_000_000",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
|
||||
assert len(json_got) == 2
|
||||
|
||||
|
||||
def test_query_max_size_1():
|
||||
""" test query --max-size """
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.cli import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--max-size", "500 kB"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
|
||||
assert len(json_got) == 1
|
||||
|
||||
|
||||
def test_query_max_size_2():
|
||||
""" test query --max-size """
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.cli import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--max-size", "500_000"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
|
||||
assert len(json_got) == 1
|
||||
|
||||
|
||||
def test_query_min_max_size():
|
||||
""" test query --max-size with --min-size"""
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.cli import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
[
|
||||
"--json",
|
||||
"--db",
|
||||
os.path.join(cwd, PHOTOS_DB_15_7),
|
||||
"--min-size",
|
||||
"48MB",
|
||||
"--max-size",
|
||||
"49MB",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
json_got = json.loads(result.output)
|
||||
|
||||
assert len(json_got) == 1
|
||||
|
||||
|
||||
def test_query_min_size_error():
|
||||
""" test query --max-size with invalid size """
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import osxphotos
|
||||
from osxphotos.cli import query
|
||||
|
||||
runner = CliRunner()
|
||||
cwd = os.getcwd()
|
||||
result = runner.invoke(
|
||||
query,
|
||||
["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_7), "--min-size", "500 foo"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ def test_lookup_multi(photosdb_places):
|
||||
|
||||
for subst in TEMPLATE_SUBSTITUTIONS_MULTI_VALUED:
|
||||
lookup_str = re.match(r"\{([^\\,}]+)\}", subst).group(1)
|
||||
if subst in ["{exiftool}", "{photo}"]:
|
||||
if subst in ["{exiftool}", "{photo}", "{function}"]:
|
||||
continue
|
||||
lookup = template.get_template_value_multi(lookup_str, path_sep=os.path.sep)
|
||||
assert isinstance(lookup, list)
|
||||
@@ -965,3 +965,20 @@ def test_conditional(photosdb):
|
||||
for template in UUID_CONDITIONAL[uuid]:
|
||||
rendered, _ = photo.render_template(template)
|
||||
assert sorted(rendered) == sorted(UUID_CONDITIONAL[uuid][template])
|
||||
|
||||
|
||||
def test_function(photosdb):
|
||||
""" Test {function} """
|
||||
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
|
||||
rendered, _ = photo.render_template("{function:tests/template_function.py::foo}")
|
||||
assert rendered == [f"{photo.original_filename}-FOO"]
|
||||
|
||||
|
||||
def test_function_bad(photosdb):
|
||||
""" Test invalid {function} """
|
||||
photo = photosdb.get_photo(UUID_MULTI_KEYWORDS)
|
||||
with pytest.raises(ValueError):
|
||||
rendered, _ = photo.render_template(
|
||||
"{function:tests/template_function.py::foobar}"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user