diff --git a/osxphotos/__init__.py b/osxphotos/__init__.py index 356abdc8..4a93a89d 100644 --- a/osxphotos/__init__.py +++ b/osxphotos/__init__.py @@ -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 diff --git a/osxphotos/_version.py b/osxphotos/_version.py index bf80cff8..d39e259a 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.42.4" +__version__ = "0.42.5" diff --git a/osxphotos/cli.py b/osxphotos/cli.py index 568242be..e604bcec 100644 --- a/osxphotos/cli.py +++ b/osxphotos/cli.py @@ -51,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 @@ -72,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 @@ -223,7 +211,7 @@ def deleted_options(f): return f -def query_options(f): +def QUERY_OPTIONS(f): o = click.option options = [ o( @@ -519,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, @@ -1338,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: @@ -1424,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, @@ -1446,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, @@ -1497,6 +1484,17 @@ def export( 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 @@ -1679,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( @@ -1847,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: @@ -1869,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, @@ -1891,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, @@ -1939,6 +1936,17 @@ def query( 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_) @@ -2048,417 +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, - min_size=None, - max_size=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 min_size: - photos = [p for p in photos if bitmath.Byte(p.original_filesize) >= min_size] - - if max_size: - photos = [p for p in photos if bitmath.Byte(p.original_filesize) <= max_size] - - 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, diff --git a/osxphotos/photoinfo/__init__.py b/osxphotos/photoinfo/__init__.py index e6c5624b..3f1609d4 100644 --- a/osxphotos/photoinfo/__init__.py +++ b/osxphotos/photoinfo/__init__.py @@ -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 \ No newline at end of file diff --git a/osxphotos/photosdb/photosdb.py b/osxphotos/photosdb/photosdb.py index b74fe7a4..b5fdecf9 100644 --- a/osxphotos/photosdb/photosdb.py +++ b/osxphotos/photosdb/photosdb.py @@ -12,6 +12,9 @@ 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 +50,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 +2837,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 +3192,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 + diff --git a/osxphotos/queryoptions.py b/osxphotos/queryoptions.py new file mode 100644 index 00000000..42713f79 --- /dev/null +++ b/osxphotos/queryoptions.py @@ -0,0 +1,104 @@ +""" 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) + + # def init_with_values( + # self, + # 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, + # **kwargs + # ): + # self.keyword = keyword + # self.person = person + # self.album = album + # self.folder = folder + # self.uuid = uuid + # self.title = title + # self.no_title + + # print(asdict(self)) + diff --git a/osxphotos/utils.py b/osxphotos/utils.py index 7f899244..f42734f6 100644 --- a/osxphotos/utils.py +++ b/osxphotos/utils.py @@ -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