Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8ea0b0452 | ||
|
|
81fd51c793 | ||
|
|
648d399524 | ||
|
|
345c052353 | ||
|
|
952f1a6c3c | ||
|
|
7ae5b8aae7 | ||
|
|
2e189d771e |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -4,6 +4,21 @@ 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
|
||||
|
||||
24
README.md
24
README.md
@@ -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
|
||||
@@ -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
|
||||
@@ -2893,6 +2913,10 @@ 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|
|
||||
|
||||
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: d8381561e4b55688141d3bad9c7d6dc7
|
||||
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.2 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.2',
|
||||
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.2 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.2 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.2 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.2 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.2 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.2 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
@@ -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.3"
|
||||
__version__ = "0.42.6"
|
||||
|
||||
522
osxphotos/cli.py
522
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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)
|
||||
@@ -373,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5861,3 +5861,135 @@ def test_bad_query_eval():
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user