diff --git a/README.md b/README.md index 73229fb2..19a0c240 100644 --- a/README.md +++ b/README.md @@ -107,16 +107,21 @@ Options: order: 1. last opened library, 2. system library, 3. ~/Pictures/Photos Library.photoslibrary - --keyword KEYWORD Search for keyword KEYWORD. If more than one - keyword, treated as "OR", e.g. find photos - match any keyword - --person PERSON Search for person PERSON. If more than one - person, treated as "OR", e.g. find photos - match any person - --album ALBUM Search for album ALBUM. If more than one - album, treated as "OR", e.g. find photos - match any album - --uuid UUID Search for UUID(s). + --keyword KEYWORD Search for photos with keyword KEYWORD. If + more than one keyword, treated as "OR", e.g. + find photos match any keyword + --person PERSON Search for photos with person PERSON. If + more than one person, treated as "OR", e.g. + find photos match any person + --album ALBUM Search for photos in album ALBUM. If more + than one album, treated as "OR", e.g. find + photos match any album + --folder FOLDER Search for photos in an album in folder + FOLDER. If more than one folder, treated as + "OR", e.g. find photos in any FOLDER. Only + searches top level folders (e.g. does not + look at subfolders) + --uuid UUID Search for photos with UUID(s). --title TITLE Search for TITLE in title of photo. --no-title Search for photos with no title. --description DESC Search for DESC in description of photo. @@ -232,6 +237,11 @@ Options: output directory in the form '{name,DEFAULT}'. See below for additional details on templating system. + --no-extended-attributes Don't copy extended attributes when + exporting. You only need this if exporting + to a filesystem that doesn't support Mac OS + extended attributes. Only use this if you + get an error while exporting. -h, --help Show this message and exit. **Templating System** @@ -334,10 +344,13 @@ exported, one to each directory. For example: --directory of the following directories if the photos were created in 2019 and were in albums 'Vacation' and 'Family': 2019/Vacation, 2019/Family -Substitution Description -{album} Album(s) photo is contained in -{keyword} Keyword(s) assigned to photo -{person} Person(s) / face(s) in a photo +Substitution Description +{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 ``` Example: export all photos to ~/Desktop/export, including edited versions and live photo movies, group in folders by date created diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index 1a74bb4a..1cf1b656 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -187,7 +187,7 @@ def query_options(f): metavar="KEYWORD", default=None, multiple=True, - help="Search for keyword KEYWORD. " + help="Search for photos with keyword KEYWORD. " 'If more than one keyword, treated as "OR", e.g. find photos match any keyword', ), o( @@ -195,7 +195,7 @@ def query_options(f): metavar="PERSON", default=None, multiple=True, - help="Search for person PERSON. " + help="Search for photos with person PERSON. " 'If more than one person, treated as "OR", e.g. find photos match any person', ), o( @@ -203,15 +203,24 @@ def query_options(f): metavar="ALBUM", default=None, multiple=True, - help="Search for album ALBUM. " + help="Search for photos in album ALBUM. " 'If more than one album, treated as "OR", e.g. find photos match any album', ), + o( + "--folder", + metavar="FOLDER", + default=None, + multiple=True, + help="Search for photos in an album in folder FOLDER. " + 'If more than one folder, treated as "OR", e.g. find photos in any FOLDER. ' + "Only searches top level folders (e.g. does not look at subfolders)", + ), o( "--uuid", metavar="UUID", default=None, multiple=True, - help="Search for UUID(s).", + help="Search for photos with UUID(s).", ), o( "--title", @@ -670,6 +679,7 @@ def query( keyword, person, album, + folder, uuid, title, no_title, @@ -728,6 +738,7 @@ def query( keyword, person, album, + folder, uuid, edited, external_edit, @@ -781,6 +792,7 @@ def query( keyword=keyword, person=person, album=album, + folder=folder, uuid=uuid, title=title, no_title=no_title, @@ -932,6 +944,7 @@ def export( keyword, person, album, + folder, uuid, title, no_title, @@ -1051,6 +1064,7 @@ def export( keyword=keyword, person=person, album=album, + folder=folder, uuid=uuid, title=title, no_title=no_title, @@ -1270,6 +1284,7 @@ def _query( keyword=None, person=None, album=None, + folder=None, uuid=None, title=None, no_title=None, @@ -1316,10 +1331,10 @@ def _query( place=None, no_place=None, ): - """ run a query against PhotosDB to extract the photos based on user supply criteria """ - """ used by query and export commands """ - """ arguments must be passed in same order as query and export """ - """ if either is modified, need to ensure all three functions are updated """ + """ run a query against PhotosDB to extract the photos based on user supply criteria + used by query and export commands + arguments must be passed in same order as query and export + if either is modified, need to ensure all three functions are updated """ photosdb = osxphotos.PhotosDB(dbfile=db) photos = photosdb.photos( @@ -1333,6 +1348,21 @@ def _query( to_date=to_date, ) + 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 diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 2d1f18d3..b52d5384 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.27.3" +__version__ = "0.27.4" diff --git a/tests/test_cli.py b/tests/test_cli.py index 5d023dc8..a2b051a5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,10 +2,12 @@ import pytest from click.testing import CliRunner CLI_PHOTOS_DB = "tests/Test-10.15.1.photoslibrary" -LIVE_PHOTOS_DB = "tests/Test-Cloud-10.15.1.photoslibrary/database/photos.db" +LIVE_PHOTOS_DB = "tests/Test-Cloud-10.15.1.photoslibrary" RAW_PHOTOS_DB = "tests/Test-RAW-10.15.1.photoslibrary" PLACES_PHOTOS_DB = "tests/Test-Places-Catalina-10_15_1.photoslibrary" PLACES_PHOTOS_DB_13 = "tests/Test-Places-High-Sierra-10.13.6.photoslibrary" +PHOTOS_DB_15_4 = "tests/Test-10.15.4.photoslibrary" +PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary" CLI_OUTPUT_NO_SUBCOMMAND = [ "Options:", @@ -658,3 +660,84 @@ def test_no_place_15(): assert len(json_got) == 1 # single element assert json_got[0]["uuid"] == "A9B73E13-A6F2-4915-8D67-7213B39BAE9F" + + +def test_no_folder_1_15(): + # test --folder on 10.15 + import json + import os + import os.path + import osxphotos + from osxphotos.__main__ import query + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + query, [os.path.join(cwd, PHOTOS_DB_15_4), "--json", "--folder", "Folder1"] + ) + assert result.exit_code == 0 + json_got = json.loads(result.output) + + assert len(json_got) == 2 # single element + for item in json_got: + assert item["uuid"] in [ + "3DD2C897-F19E-4CA6-8C22-B027D5A71907", + "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", + ] + assert item["albums"] == ["AlbumInFolder"] + + +def test_no_folder_2_15(): + # test --folder with --uuid on 10.15 + import json + import os + import os.path + import osxphotos + from osxphotos.__main__ import query + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + query, + [ + os.path.join(cwd, PHOTOS_DB_15_4), + "--json", + "--folder", + "Folder1", + "--uuid", + "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", + ], + ) + assert result.exit_code == 0 + json_got = json.loads(result.output) + + assert len(json_got) == 1 # single element + for item in json_got: + assert item["uuid"] == "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51" + assert item["albums"] == ["AlbumInFolder"] + + +def test_no_folder_1_14(caplog): + # test --folder on 10.14 + import json + import os + import os.path + import osxphotos + from osxphotos.__main__ import query + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + query, [os.path.join(cwd, PHOTOS_DB_14_6), "--json", "--folder", "Folder1"] + ) + assert result.exit_code == 0 + json_got = json.loads(result.output) + + assert len(json_got) == 0 # single element + assert "not yet implemented" in caplog.text diff --git a/tests/test_template.py b/tests/test_template.py index 73ee6093..4d3fd666 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,13 +1,21 @@ """ Test template.py """ import pytest -PHOTOS_DB_1 = "./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db" -PHOTOS_DB_2 = "./tests/Test-10.15.1.photoslibrary/database/photos.db" +PHOTOS_DB_PLACES = ( + "./tests/Test-Places-Catalina-10_15_1.photoslibrary/database/photos.db" +) +PHOTOS_DB_15_1 = "./tests/Test-10.15.1.photoslibrary/database/photos.db" +PHOTOS_DB_15_4 = "./tests/Test-10.15.4.photoslibrary/database/photos.db" +PHOTOS_DB_14_6 = "./tests/Test-10.14.6.photoslibrary/database/photos.db" + UUID_DICT = { "place_dc": "128FB4C6-0B16-4E7D-9108-FB2E90DA1546", "1_1_2": "1EB2B765-0765-43BA-A90C-0D0580E6172C", "2_1_1": "D79B8D77-BFFC-460B-9312-034F2877D35B", "0_2_0": "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4", + "folder_album_1": "3DD2C897-F19E-4CA6-8C22-B027D5A71907", + "folder_album_no_folder": "D79B8D77-BFFC-460B-9312-034F2877D35B", + "mojave_no_folder": "15uNd7%8RguTEgNPKHfTWw", } TEMPLATE_VALUES = { @@ -55,7 +63,7 @@ def test_lookup(): TEMPLATE_SUBSTITUTIONS, ) - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] for subst in TEMPLATE_SUBSTITUTIONS: @@ -71,7 +79,7 @@ def test_subst(): from osxphotos.template import render_filepath_template locale.setlocale(locale.LC_ALL, "en_US") - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] for template in TEMPLATE_VALUES: @@ -86,7 +94,7 @@ def test_subst_default_val(): from osxphotos.template import render_filepath_template locale.setlocale(locale.LC_ALL, "en_US") - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] template = "{place.name.area_of_interest,UNKNOWN}" @@ -101,7 +109,7 @@ def test_subst_default_val_2(): from osxphotos.template import render_filepath_template locale.setlocale(locale.LC_ALL, "en_US") - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] template = "{place.name.area_of_interest,}" @@ -116,7 +124,7 @@ def test_subst_unknown_val(): from osxphotos.template import render_filepath_template locale.setlocale(locale.LC_ALL, "en_US") - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] template = "{created.year}/{foo}" @@ -134,7 +142,7 @@ def test_subst_double_brace(): import osxphotos from osxphotos.template import render_filepath_template - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] template = "{created.year}/{{foo}}" @@ -150,7 +158,7 @@ def test_subst_unknown_val_with_default(): from osxphotos.template import render_filepath_template locale.setlocale(locale.LC_ALL, "en_US") - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_1) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_PLACES) photo = photosdb.photos(uuid=[UUID_DICT["place_dc"]])[0] template = "{created.year}/{foo,bar}" @@ -165,7 +173,7 @@ def test_subst_multi_1_1_2(): import osxphotos from osxphotos.template import render_filepath_template - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) photo = photosdb.photos(uuid=[UUID_DICT["1_1_2"]])[0] template = "{created.year}/{album}/{keyword}/{person}" @@ -180,7 +188,7 @@ def test_subst_multi_2_1_1(): import osxphotos from osxphotos.template import render_filepath_template - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) # one album, one keyword, two persons photo = photosdb.photos(uuid=[UUID_DICT["2_1_1"]])[0] @@ -200,7 +208,7 @@ def test_subst_multi_2_1_1_single(): import osxphotos from osxphotos.template import render_filepath_template - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) # one album, one keyword, two persons photo = photosdb.photos(uuid=[UUID_DICT["2_1_1"]])[0] @@ -216,7 +224,7 @@ def test_subst_multi_0_2_0(): import osxphotos from osxphotos.template import render_filepath_template - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) # one album, one keyword, two persons photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0] @@ -232,7 +240,7 @@ def test_subst_multi_0_2_0_single(): import osxphotos from osxphotos.template import render_filepath_template - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) # one album, one keyword, two persons photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0] @@ -248,7 +256,7 @@ def test_subst_multi_0_2_0_default_val(): import osxphotos from osxphotos.template import render_filepath_template - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) # one album, one keyword, two persons photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0] @@ -264,7 +272,7 @@ def test_subst_multi_0_2_0_default_val_unknown_val(): import osxphotos from osxphotos.template import render_filepath_template - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) # one album, one keyword, two persons photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0] @@ -286,7 +294,7 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2(): import osxphotos from osxphotos.template import render_filepath_template - photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_2) + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_1) # one album, one keyword, two persons photo = photosdb.photos(uuid=[UUID_DICT["0_2_0"]])[0] @@ -298,3 +306,52 @@ def test_subst_multi_0_2_0_default_val_unknown_val_2(): rendered, unknown = render_filepath_template(template, photo) assert sorted(rendered) == sorted(expected) assert unknown == ["foo"] + + +def test_subst_multi_folder_albums_1(): + """ Test substitutions for folder_album are correct """ + import osxphotos + from osxphotos.template import render_filepath_template + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4) + + # photo in an album in a folder + photo = photosdb.photos(uuid=[UUID_DICT["folder_album_1"]])[0] + template = "{folder_album}" + expected = ["Folder1/SubFolder2/AlbumInFolder"] + rendered, unknown = render_filepath_template(template, photo) + assert sorted(rendered) == sorted(expected) + assert unknown == [] + + +def test_subst_multi_folder_albums_2(): + """ Test substitutions for folder_album are correct """ + import osxphotos + from osxphotos.template import render_filepath_template + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_15_4) + + # photo in an album in a folder + photo = photosdb.photos(uuid=[UUID_DICT["folder_album_no_folder"]])[0] + template = "{folder_album}" + expected = ["Pumpkin Farm", "Test Album"] + rendered, unknown = render_filepath_template(template, photo) + assert sorted(rendered) == sorted(expected) + assert unknown == [] + + +def test_subst_multi_folder_albums_3(caplog): + """ Test substitutions for folder_album on < Photos 5 (not implemented) """ + import osxphotos + from osxphotos.template import render_filepath_template + + photosdb = osxphotos.PhotosDB(dbfile=PHOTOS_DB_14_6) + + # photo in an album in a folder + photo = photosdb.photos(uuid=[UUID_DICT["mojave_no_folder"]])[0] + template = "{folder_album}" + expected = ["Pumpkin Farm", "Test Album (1)"] + rendered, unknown = render_filepath_template(template, photo) + assert sorted(rendered) == sorted(expected) + assert unknown == [] + assert "not yet implemented" in caplog.text