diff --git a/README.md b/README.md index cbe311f6..472b5bb7 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,10 @@ Options: Search by end item date, e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o TZ). + --deleted Include photos from the 'Recently Deleted' + folder. + --deleted-only Include only photos from the 'Recently + Deleted' folder. --update Only export new or updated files. See notes below on export and --update. --dry-run Dry run (test) the export but don't actually @@ -520,6 +524,10 @@ Example: export photos to file structure based on 4-digit year and full name of `osxphotos export ~/Desktop/export --directory "{created.year}/{created.month}"` +Example: export default library using 'country name/year' as output directory (but use "NoCountry/year" if country not specified), add persons, album names, and year as keywords, write exif metadata to files when exporting, update only changed files, print verbose ouput + +`osxphotos export ~/Desktop/export --directory "{place.name.country,NoCountry}/{created.year}" --person-keyword --album-keyword --keyword-template "{created.year}" --exiftool --update --verbose` + ## Example uses of the package diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index a034f1e0..0fd4634c 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -236,6 +236,25 @@ JSON_OPTION = click.option( ) +def deleted_options(f): + o = click.option + options = [ + o( + "--deleted", + is_flag=True, + help="Include photos from the 'Recently Deleted' folder.", + ), + o( + "--deleted-only", + is_flag=True, + help="Include only photos from the 'Recently Deleted' folder.", + ), + ] + for o in options[::-1]: + f = o(f) + return f + + def query_options(f): o = click.option options = [ @@ -745,10 +764,11 @@ def places(ctx, cli_obj, db, json_, photos_library): @cli.command() @DB_OPTION @JSON_OPTION +@deleted_options @DB_ARGUMENT @click.pass_obj @click.pass_context -def dump(ctx, cli_obj, db, json_, photos_library): +def dump(ctx, cli_obj, db, json_, deleted, deleted_only, photos_library): """ Print list of all photos & associated info from the Photos library. """ db = get_photos_db(*photos_library, db, cli_obj.db) @@ -758,8 +778,20 @@ def dump(ctx, cli_obj, db, json_, photos_library): _list_libraries() return + # check exclusive options + if deleted and deleted_only: + click.echo("Incompatible dump options", err=True) + click.echo(cli.commands["dump"].get_help(ctx), err=True) + return + photosdb = osxphotos.PhotosDB(dbfile=db) - photos = photosdb.photos(movies=True) + if deleted or deleted_only: + photos = photosdb.photos(movies=True, intrash=True) + else: + photos = [] + if not deleted_only: + photos += photosdb.photos(movies=True) + print_photo_info(photos, json_ or cli_obj.json) @@ -815,6 +847,7 @@ def _list_libraries(json_=False, error=True): @DB_OPTION @JSON_OPTION @query_options +@deleted_options @click.option("--missing", is_flag=True, help="Search for photos missing from disk.") @click.option( "--not-missing", @@ -901,6 +934,8 @@ def query( place, no_place, label, + deleted, + deleted_only, ): """ Query the Photos database using 1 or more search options; if more than one option is provided, they are treated as "AND" @@ -942,9 +977,12 @@ def query( (selfie, not_selfie), (panorama, not_panorama), (any(place), no_place), + (deleted, deleted_only), ] # print help if no non-exclusive term or a double exclusive term is given - if not any(nonexclusive + [b ^ n for b, n in exclusive]): + if any(all(bb) for bb in exclusive) or not any( + nonexclusive + [b ^ n for b, n in exclusive] + ): click.echo("Incompatible query options", err=True) click.echo(cli.commands["query"].get_help(ctx), err=True) return @@ -1018,6 +1056,8 @@ def query( place=place, no_place=no_place, label=label, + deleted=deleted, + deleted_only=deleted_only, ) # below needed for to make CliRunner work for testing @@ -1029,6 +1069,7 @@ def query( @DB_OPTION @click.option("--verbose", "-V", "verbose_", is_flag=True, help="Print verbose output.") @query_options +@deleted_options @click.option( "--update", is_flag=True, @@ -1252,6 +1293,8 @@ def export( no_place, no_extended_attributes, label, + deleted, + deleted_only, ): """ Export photos from the Photos database. Export path DEST is required. @@ -1290,6 +1333,7 @@ def export( (export_by_date, directory), (export_as_hardlink, exiftool), (any(place), no_place), + (deleted, deleted_only), ] if any(all(bb) for bb in exclusive): click.echo("Incompatible export options", err=True) @@ -1411,6 +1455,8 @@ def export( place=place, no_place=no_place, label=label, + deleted=deleted, + deleted_only=deleted_only, ) results_exported = [] @@ -1597,6 +1643,7 @@ def print_photo_info(photos, json=False): "has_raw", "uti_raw", "path_raw", + "intrash", ] ) for p in photos: @@ -1641,6 +1688,7 @@ def print_photo_info(photos, json=False): p.has_raw, p.uti_raw, p.path_raw, + p.intrash, ] ) for row in dump: @@ -1700,6 +1748,8 @@ def _query( place=None, no_place=None, label=None, + deleted=False, + deleted_only=False, ): """ run a query against PhotosDB to extract the photos based on user supply criteria used by query and export commands @@ -1707,9 +1757,25 @@ def _query( if either is modified, need to ensure all three functions are updated """ photosdb = osxphotos.PhotosDB(dbfile=db) - photos = photosdb.photos( - uuid=uuid, images=isphoto, movies=ismovie, from_date=from_date, to_date=to_date - ) + 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, + ) if album: photos = get_photos_by_attribute(photos, "albums", album, ignore_case) diff --git a/osxphotos/_version.py b/osxphotos/_version.py index d469fb4e..ef3a4445 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,3 +1,3 @@ """ version info """ -__version__ = "0.30.1" +__version__ = "0.30.2" diff --git a/osxphotos/photoinfo/photoinfo.py b/osxphotos/photoinfo/photoinfo.py index a4773ed3..52b93cf0 100644 --- a/osxphotos/photoinfo/photoinfo.py +++ b/osxphotos/photoinfo/photoinfo.py @@ -753,6 +753,7 @@ class PhotoInfo: "place": self.place, "exif": exif, "score": score, + "intrash": self.intrash, } return yaml.dump(info, sort_keys=False) @@ -812,6 +813,7 @@ class PhotoInfo: "place": place, "exif": exif, "score": score, + "intrash": self.intrash, } return json.dumps(pic) diff --git a/tests/test_cli.py b/tests/test_cli.py index 26efec52..e2f05812 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -197,6 +197,17 @@ CLI_EXPORT_RAW_EDITED = [ ] CLI_EXPORT_RAW_EDITED_ORIGINAL = ["IMG_0476_2.CR2", "IMG_0476_2_edited.jpeg"] +CLI_UUID_DICT_15_5 = {"intrash": "71E3E212-00EB-430D-8A63-5E294B268554"} +CLI_UUID_DICT_14_6 = {"intrash": "3tljdX43R8+k6peNHVrJNQ"} + +PHOTOS_NOT_IN_TRASH_LEN_14_6 = 7 +PHOTOS_IN_TRASH_LEN_14_6 = 1 +PHOTOS_MISSING_14_6 = 1 + +PHOTOS_NOT_IN_TRASH_LEN_15_5 = 13 +PHOTOS_IN_TRASH_LEN_15_5 = 1 +PHOTOS_MISSING_15_5 = 2 + CLI_PLACES_JSON = """{"places": {"_UNKNOWN_": 1, "Maui, Wailea, Hawai'i, United States": 1, "Washington, District of Columbia, United States": 1}}""" CLI_EXIFTOOL = { @@ -957,6 +968,103 @@ def test_query_label_4(): assert len(json_got) == 6 +def test_query_deleted_deleted_only(): + """Test query with --deleted and --deleted-only""" + import json + import osxphotos + import os + import os.path + from osxphotos.__main__ import query + + runner = CliRunner() + cwd = os.getcwd() + result = runner.invoke( + query, + [ + "--json", + "--db", + os.path.join(cwd, PHOTOS_DB_15_5), + "--deleted", + "--deleted-only", + ], + ) + assert "Incompatible query options" in result.output + + +def test_query_deleted_1(): + """Test query with --deleted""" + import json + import osxphotos + import os + import os.path + from osxphotos.__main__ import query + + runner = CliRunner() + cwd = os.getcwd() + result = runner.invoke( + query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--deleted"] + ) + assert result.exit_code == 0 + json_got = json.loads(result.output) + assert len(json_got) == PHOTOS_NOT_IN_TRASH_LEN_15_5 + PHOTOS_IN_TRASH_LEN_15_5 + + +def test_query_deleted_2(): + """Test query with --deleted""" + import json + import osxphotos + import os + import os.path + from osxphotos.__main__ import query + + runner = CliRunner() + cwd = os.getcwd() + result = runner.invoke( + query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_14_6), "--deleted"] + ) + assert result.exit_code == 0 + json_got = json.loads(result.output) + assert len(json_got) == PHOTOS_NOT_IN_TRASH_LEN_14_6 + PHOTOS_IN_TRASH_LEN_14_6 + + +def test_query_deleted_3(): + """Test query with --deleted-only""" + import json + import osxphotos + import os + import os.path + from osxphotos.__main__ import query + + runner = CliRunner() + cwd = os.getcwd() + result = runner.invoke( + query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_15_5), "--deleted-only"] + ) + assert result.exit_code == 0 + json_got = json.loads(result.output) + assert len(json_got) == PHOTOS_IN_TRASH_LEN_15_5 + assert json_got[0]["intrash"] + + +def test_query_deleted_4(): + """Test query with --deleted-only""" + import json + import osxphotos + import os + import os.path + from osxphotos.__main__ import query + + runner = CliRunner() + cwd = os.getcwd() + result = runner.invoke( + query, ["--json", "--db", os.path.join(cwd, PHOTOS_DB_14_6), "--deleted-only"] + ) + assert result.exit_code == 0 + json_got = json.loads(result.output) + assert len(json_got) == PHOTOS_IN_TRASH_LEN_14_6 + assert json_got[0]["intrash"] + + def test_export_sidecar(): import glob import os @@ -1434,6 +1542,138 @@ def test_export_album_deleted_twin(): assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_DELETED_TWIN) +def test_export_deleted_1(): + """Test export with --deleted """ + import glob + import os + import os.path + import osxphotos + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] + result = runner.invoke( + export, [os.path.join(cwd, PHOTOS_DB_15_5), ".", "--deleted", *skip] + ) + assert result.exit_code == 0 + files = glob.glob("*") + assert ( + len(files) + == PHOTOS_NOT_IN_TRASH_LEN_15_5 + + PHOTOS_IN_TRASH_LEN_15_5 + - PHOTOS_MISSING_15_5 + ) + + +def test_export_deleted_2(): + """Test export with --deleted """ + import glob + import os + import os.path + import osxphotos + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] + result = runner.invoke( + export, [os.path.join(cwd, PHOTOS_DB_14_6), ".", "--deleted", *skip] + ) + assert result.exit_code == 0 + files = glob.glob("*") + assert ( + len(files) + == PHOTOS_NOT_IN_TRASH_LEN_14_6 + + PHOTOS_IN_TRASH_LEN_14_6 + - PHOTOS_MISSING_14_6 + ) + + +def test_export_not_deleted_1(): + """Test export does not find intrash files without --deleted flag """ + import glob + import os + import os.path + import osxphotos + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] + result = runner.invoke(export, [os.path.join(cwd, PHOTOS_DB_15_5), ".", *skip]) + assert result.exit_code == 0 + files = glob.glob("*") + assert len(files) == PHOTOS_NOT_IN_TRASH_LEN_15_5 - PHOTOS_MISSING_15_5 + + +def test_export_not_deleted_2(): + """Test export does not find intrash files without --deleted flag """ + import glob + import os + import os.path + import osxphotos + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] + result = runner.invoke(export, [os.path.join(cwd, PHOTOS_DB_14_6), ".", *skip]) + assert result.exit_code == 0 + files = glob.glob("*") + assert len(files) == PHOTOS_NOT_IN_TRASH_LEN_14_6 - PHOTOS_MISSING_14_6 + + +def test_export_deleted_only_1(): + """Test export with --deleted-only """ + import glob + import os + import os.path + import osxphotos + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] + result = runner.invoke( + export, [os.path.join(cwd, PHOTOS_DB_15_5), ".", "--deleted-only", *skip] + ) + assert result.exit_code == 0 + files = glob.glob("*") + assert len(files) == PHOTOS_IN_TRASH_LEN_15_5 + + +def test_export_deleted_only_2(): + """Test export with --deleted-only """ + import glob + import os + import os.path + import osxphotos + from osxphotos.__main__ import export + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + skip = ["--skip-edited", "--skip-bursts", "--skip-live", "--skip-raw"] + result = runner.invoke( + export, [os.path.join(cwd, PHOTOS_DB_14_6), ".", "--deleted-only", *skip] + ) + assert result.exit_code == 0 + files = glob.glob("*") + assert len(files) == PHOTOS_IN_TRASH_LEN_14_6 + + def test_places(): import json import os