diff --git a/README.md b/README.md index 4dea4d32..926aa873 100644 --- a/README.md +++ b/README.md @@ -374,20 +374,24 @@ Options: do not include an extension in the FILENAME template. See below for additional details on templating system. - --edited-suffix SUFFIX Optional suffix for naming edited photos. - Default name for edited photos is in form - 'photoname_edited.ext'. For example, with ' - --edited-suffix _bearbeiten', the edited - photo would be named + --edited-suffix SUFFIX Optional suffix template for naming edited + photos. Default name for edited photos is + in form 'photoname_edited.ext'. For example, + with '--edited-suffix _bearbeiten', the + edited photo would be named 'photoname_bearbeiten.ext'. The default - suffix is '_edited'. - --original-suffix SUFFIX Optional suffix for naming original photos. - Default name for original photos is in form - 'filename.ext'. For example, with '-- - original-suffix _original', the original + suffix is '_edited'. Multi-value templates + (see Templating System) are not permitted + with --edited-suffix. + --original-suffix SUFFIX Optional suffix template for naming original + photos. Default name for original photos is + in form 'filename.ext'. For example, with ' + --original-suffix _original', the original photo would be named 'filename_original.ext'. The default suffix - is '' (no suffix). + is '' (no suffix). Multi-value templates + (see Templating System) are not permitted + with --original-suffix. --use-photos-export Force the use of AppleScript or PhotoKit to export even if not missing (see also '-- download-missing' and '--use-photokit'). @@ -569,6 +573,9 @@ Substitution Description '{photo_or_video,photo=fotos;video=videos}' {hdr} Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}' +{edited} Photo has been edited (has adjustments)?; + True/False value, use in format + '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}' {created.date} Photo's creation date in ISO format, e.g. '2020-03-22' {created.year} 4-digit year of photo creation time @@ -2041,6 +2048,7 @@ The following template field substitutions are availabe for use with `PhotoInfo. |{media_type}|Special media type resolved in this precedence: selfie, time_lapse, panorama, slow_mo, screenshot, portrait, live_photo, burst, photo, video. Defaults to 'photo' or 'video' if no special type. Customize one or more media types using format: '{media_type,video=vidéo;time_lapse=vidéo_accélérée}'| |{photo_or_video}|'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'| |{hdr}|Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'| +|{edited}|Photo has been edited (has adjustments)?; True/False value, use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'| |{created.date}|Photo's creation date in ISO format, e.g. '2020-03-22'| |{created.year}|4-digit year of photo creation time| |{created.yy}|2-digit year of photo creation time| diff --git a/osxphotos/__main__.py b/osxphotos/__main__.py index a4cbdb99..b26cd84f 100644 --- a/osxphotos/__main__.py +++ b/osxphotos/__main__.py @@ -1402,16 +1402,18 @@ def query( @click.option( "--edited-suffix", metavar="SUFFIX", - help="Optional suffix for naming edited photos. Default name for edited photos is in form " + help="Optional suffix template for naming edited photos. Default name for edited photos is in form " "'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo " - f"would be named 'photoname_bearbeiten.ext'. The default suffix is '{DEFAULT_EDITED_SUFFIX}'.", + f"would be named 'photoname_bearbeiten.ext'. The default suffix is '{DEFAULT_EDITED_SUFFIX}'. " + "Multi-value templates (see Templating System) are not permitted with --edited-suffix.", ) @click.option( "--original-suffix", metavar="SUFFIX", - help="Optional suffix for naming original photos. Default name for original photos is in form " + help="Optional suffix template for naming original photos. Default name for original photos is in form " "'filename.ext'. For example, with '--original-suffix _original', the original photo " - "would be named 'filename_original.ext'. The default suffix is '' (no suffix).", + "would be named 'filename_original.ext'. The default suffix is '' (no suffix). " + "Multi-value templates (see Templating System) are not permitted with --original-suffix.", ) @click.option( "--use-photos-export", @@ -1578,8 +1580,6 @@ def export( ignore=["ctx", "cli_obj", "dest", "load_config", "save_config"], ) - # print(jpeg_quality, edited_suffix, original_suffix) - global VERBOSE VERBOSE = bool(verbose) @@ -1716,11 +1716,7 @@ def export( ("jpeg_quality", ("convert_to_jpeg")), ] try: - cfg.validate( - exclusive=exclusive_options, - dependent=dependent_options, - cli=True, - ) + cfg.validate(exclusive=exclusive_options, dependent=dependent_options, cli=True) except ConfigOptionsInvalidError as e: click.echo(f"Incompatible export options: {e.message}", err=True) raise click.Abort() @@ -1736,8 +1732,6 @@ def export( DEFAULT_ORIGINAL_SUFFIX if original_suffix is None else original_suffix ) - # print(jpeg_quality, edited_suffix, original_suffix) - if not os.path.isdir(dest): click.echo(f"DEST {dest} must be valid path", err=True) raise click.Abort() @@ -2693,10 +2687,25 @@ def export_photo( filenames = get_filenames_from_template(photo, filename_template, original_name) for filename in filenames: if original_suffix: + rendered_suffix, unmatched = photo.render_template( + original_suffix, filename=True + ) + if not rendered_suffix or unmatched: + raise click.BadOptionUsage( + "original_suffix", + f"Invalid template for --original-suffix '{original_suffix}': results={rendered_suffix} unmatched={unmatched}", + ) + if len(rendered_suffix) > 1: + raise click.BadOptionUsage( + "original_suffix", + f"Invalid template for --original-suffix: may not use multi-valued templates: '{original_suffix}': results={rendered_suffix}", + ) + rendered_suffix = rendered_suffix[0] + original_filename = pathlib.Path(filename) original_filename = ( original_filename.parent - / f"{original_filename.stem}{original_suffix}{original_filename.suffix}" + / f"{original_filename.stem}{rendered_suffix}{original_filename.suffix}" ) original_filename = str(original_filename) else: @@ -2813,7 +2822,30 @@ def export_photo( # use filename suffix which might be wrong, # will be corrected by use_photos_export edited_ext = pathlib.Path(photo.filename).suffix - edited_filename = f"{edited_filename.stem}{edited_suffix}{edited_ext}" + + if edited_suffix: + rendered_suffix, unmatched = photo.render_template( + edited_suffix, filename=True + ) + + if not rendered_suffix or unmatched: + raise click.BadOptionUsage( + "edited_suffix", + f"Invalid template for --edited-suffix '{edited_suffix}': results={rendered_suffix} unmatched={unmatched}", + ) + if len(rendered_suffix) > 1: + raise click.BadOptionUsage( + "edited_suffix", + f"Invalid template for --edited-suffix: may not use multi-valued templates: '{edited_suffix}': results={rendered_suffix}", + ) + rendered_suffix = rendered_suffix[0] + + edited_filename = ( + f"{edited_filename.stem}{rendered_suffix}{edited_ext}" + ) + else: + edited_filename = f"{edited_filename.stem}{edited_ext}" + verbose_( f"Exporting edited version of {photo.original_filename} ({photo.filename}) as {edited_filename}" ) diff --git a/osxphotos/_version.py b/osxphotos/_version.py index 7e796e39..9a344e50 100644 --- a/osxphotos/_version.py +++ b/osxphotos/_version.py @@ -1,5 +1,5 @@ """ version info """ -__version__ = "0.38.3" +__version__ = "0.38.4" diff --git a/osxphotos/phototemplate.py b/osxphotos/phototemplate.py index 8eb3c341..1d1d5bbb 100644 --- a/osxphotos/phototemplate.py +++ b/osxphotos/phototemplate.py @@ -52,6 +52,7 @@ TEMPLATE_SUBSTITUTIONS = { ), "{photo_or_video}": "'photo' or 'video' depending on what type the image is. To customize, use default value as in '{photo_or_video,photo=fotos;video=videos}'", "{hdr}": "Photo is HDR?; True/False value, use in format '{hdr?VALUE_IF_TRUE,VALUE_IF_FALSE}'", + "{edited}": "Photo has been edited (has adjustments)?; True/False value, use in format '{edited?VALUE_IF_TRUE,VALUE_IF_FALSE}'", "{created.date}": "Photo's creation date in ISO format, e.g. '2020-03-22'", "{created.year}": "4-digit year of photo creation time", "{created.yy}": "2-digit year of photo creation time", @@ -632,7 +633,9 @@ class PhotoTemplate: elif field == "photo_or_video": value = self.get_photo_video_type(default) elif field == "hdr": - value = self.get_photo_hdr(default, bool_val) + value = self.get_photo_bool_attribute("hdr", default, bool_val) + elif field == "edited": + value = self.get_photo_bool_attribute("hasadjustments", default, bool_val) elif field == "created.date": value = DateTimeFormatter(self.photo.date).date elif field == "created.year": @@ -962,8 +965,10 @@ class PhotoTemplate: else: return default_dict["photo"] - def get_photo_hdr(self, default, bool_val): - if self.photo.hdr: + def get_photo_bool_attribute(self, attr, default, bool_val): + # get value for a PhotoInfo bool attribute + val = getattr(self.photo, attr) + if val: return bool_val else: return default diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a85c08b..69ac1070 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -66,7 +66,9 @@ CLI_EXPORT_FILENAMES_ALBUM_UNICODE = ["IMG_4547.jpg"] CLI_EXPORT_FILENAMES_DELETED_TWIN = ["wedding.jpg", "wedding_edited.jpeg"] CLI_EXPORT_EDITED_SUFFIX = "_bearbeiten" +CLI_EXPORT_EDITED_SUFFIX_TEMPLATE = "{edited?_edited,}" CLI_EXPORT_ORIGINAL_SUFFIX = "_original" +CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE = "{edited?_original,}" CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [ "Pumkins1.jpg", @@ -79,6 +81,17 @@ CLI_EXPORT_FILENAMES_EDITED_SUFFIX = [ "wedding_bearbeiten.jpeg", ] +CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE = [ + "Pumkins1.jpg", + "Pumkins2.jpg", + "Pumpkins3.jpg", + "St James Park.jpg", + "St James Park_edited.jpeg", + "Tulips.jpg", + "wedding.jpg", + "wedding_edited.jpeg", +] + CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [ "Pumkins1_original.jpg", "Pumkins2_original.jpg", @@ -90,6 +103,17 @@ CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX = [ "wedding_edited.jpeg", ] +CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE = [ + "Pumkins1.jpg", + "Pumkins2.jpg", + "Pumpkins3.jpg", + "St James Park_original.jpg", + "St James Park_edited.jpeg", + "Tulips.jpg", + "wedding_original.jpg", + "wedding_edited.jpeg", +] + CLI_EXPORT_FILENAMES_CURRENT = [ "1EB2B765-0765-43BA-A90C-0D0580E6172C.jpeg", "3DD2C897-F19E-4CA6-8C22-B027D5A71907.jpeg", @@ -1087,6 +1111,33 @@ def test_export_edited_suffix(): assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX) +def test_export_edited_suffix_template(): + """ test export with --edited-suffix template """ + 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(): + result = runner.invoke( + export, + [ + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "--edited-suffix", + CLI_EXPORT_EDITED_SUFFIX_TEMPLATE, + "-V", + ], + ) + assert result.exit_code == 0 + files = glob.glob("*") + assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_EDITED_SUFFIX_TEMPLATE) + + def test_export_original_suffix(): """ test export with --original-suffix """ import glob @@ -1114,6 +1165,33 @@ def test_export_original_suffix(): assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX) +def test_export_original_suffix_template(): + """ test export with --original-suffix template """ + 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(): + result = runner.invoke( + export, + [ + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "--original-suffix", + CLI_EXPORT_ORIGINAL_SUFFIX_TEMPLATE, + "-V", + ], + ) + assert result.exit_code == 0 + files = glob.glob("*") + assert sorted(files) == sorted(CLI_EXPORT_FILENAMES_ORIGINAL_SUFFIX_TEMPLATE) + + @pytest.mark.skipif( "OSXPHOTOS_TEST_CONVERT" not in os.environ, reason="Skip if running in Github actions, no GPU.", diff --git a/tests/test_template.py b/tests/test_template.py index a072f9b5..532ffcd6 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -59,10 +59,16 @@ TEMPLATE_VALUES_TITLE = { } # Boolean type values that render to True -UUID_BOOL_VALUES = {"hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15"} +UUID_BOOL_VALUES = { + "hdr": "D11D25FF-5F31-47D2-ABA9-58418878DC15", + "edited": "51F2BEF7-431A-4D31-8AC1-3284A57826AE", +} # Boolean type values that render to False -UUID_BOOL_VALUES_NOT = {"hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE"} +UUID_BOOL_VALUES_NOT = { + "hdr": "51F2BEF7-431A-4D31-8AC1-3284A57826AE", + "edited": "CCBE0EB9-AE9F-4479-BFFD-107042C75227", +} # for exiftool template UUID_EXIFTOOL = {