Added --filename to CLI, closes #89
This commit is contained in:
27
README.md
27
README.md
@@ -282,6 +282,12 @@ Options:
|
|||||||
output directory in the form
|
output directory in the form
|
||||||
'{name,DEFAULT}'. See below for additional
|
'{name,DEFAULT}'. See below for additional
|
||||||
details on templating system.
|
details on templating system.
|
||||||
|
--filename FILENAME Optional template for specifying name of
|
||||||
|
output file in the form '{name,DEFAULT}'.
|
||||||
|
File extension will be added automatically--
|
||||||
|
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.
|
--edited-suffix SUFFIX Optional suffix for naming edited photos.
|
||||||
Default name for edited photos is in form
|
Default name for edited photos is in form
|
||||||
'photoname_edited.ext'. For example, with '
|
'photoname_edited.ext'. For example, with '
|
||||||
@@ -326,13 +332,16 @@ option to re-export the entire library thus rebuilding the
|
|||||||
|
|
||||||
** Templating System **
|
** Templating System **
|
||||||
|
|
||||||
With the --directory option you may specify a template for the export
|
With the --directory and --filename options you may specify a template for the
|
||||||
directory. This directory will be appended to the export path specified in
|
export directory or filename, respectively. The directory will be appended to
|
||||||
the export DEST argument to export. For example, if template is
|
the export path specified in the export DEST argument to export. For example,
|
||||||
'{created.year}/{created.month}', and export desitnation DEST is
|
if template is '{created.year}/{created.month}', and export desitnation DEST
|
||||||
'/Users/maria/Pictures/export', the actual export directory for a photo would
|
is '/Users/maria/Pictures/export', the actual export directory for a photo
|
||||||
be '/Users/maria/Pictures/export/2020/March' if the photo was created in March
|
would be '/Users/maria/Pictures/export/2020/March' if the photo was created in
|
||||||
2020.
|
March 2020. Some template substitutions may result in more than one value, for
|
||||||
|
example '{album}' if photo is in more than one album or '{keyword}' if photo
|
||||||
|
has more than one keyword. In this case, more than one copy of the photo will
|
||||||
|
be exported, each in a separate directory or with a different filename.
|
||||||
|
|
||||||
The templating system may also be used with the --keyword-template option to
|
The templating system may also be used with the --keyword-template option to
|
||||||
set keywords on export (with --exiftool or --sidecar), for example, to set a
|
set keywords on export (with --exiftool or --sidecar), for example, to set a
|
||||||
@@ -358,9 +367,7 @@ contain a brace symbol ('{' or '}').
|
|||||||
|
|
||||||
If you do not specify a default value and the template substitution has no
|
If you do not specify a default value and the template substitution has no
|
||||||
value, '_' (underscore) will be used as the default value. For example, in the
|
value, '_' (underscore) will be used as the default value. For example, in the
|
||||||
above example, this would result in '2020/_/photoname.jpg' if address was null
|
above example, this would result in '2020/_/photoname.jpg' if address was null.
|
||||||
I plan to eventually extend the templating system to the exported filename so
|
|
||||||
you can specify the filename using a template.
|
|
||||||
|
|
||||||
Substitution Description
|
Substitution Description
|
||||||
{name} Current filename of the photo
|
{name} Current filename of the photo
|
||||||
|
|||||||
@@ -15,23 +15,20 @@ import yaml
|
|||||||
from pathvalidate import (
|
from pathvalidate import (
|
||||||
is_valid_filename,
|
is_valid_filename,
|
||||||
is_valid_filepath,
|
is_valid_filepath,
|
||||||
sanitize_filepath,
|
|
||||||
sanitize_filename,
|
sanitize_filename,
|
||||||
|
sanitize_filepath,
|
||||||
)
|
)
|
||||||
|
|
||||||
import osxphotos
|
import osxphotos
|
||||||
|
|
||||||
from ._constants import _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE
|
from ._constants import _EXIF_TOOL_URL, _PHOTOS_4_VERSION, _UNKNOWN_PLACE
|
||||||
from .datetime_formatter import DateTimeFormatter
|
from ._export_db import ExportDB, ExportDBInMemory
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
|
from .datetime_formatter import DateTimeFormatter
|
||||||
from .exiftool import get_exiftool_path
|
from .exiftool import get_exiftool_path
|
||||||
from .fileutil import FileUtil, FileUtilNoOp
|
from .fileutil import FileUtil, FileUtilNoOp
|
||||||
from .photoinfo import ExportResults
|
from .photoinfo import ExportResults
|
||||||
from .phototemplate import (
|
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
|
||||||
TEMPLATE_SUBSTITUTIONS,
|
|
||||||
TEMPLATE_SUBSTITUTIONS_MULTI_VALUED,
|
|
||||||
)
|
|
||||||
from ._export_db import ExportDB, ExportDBInMemory
|
|
||||||
|
|
||||||
# global variable to control verbose output
|
# global variable to control verbose output
|
||||||
# set via --verbose/-V
|
# set via --verbose/-V
|
||||||
@@ -134,13 +131,18 @@ class ExportCommand(click.Command):
|
|||||||
formatter.write_text("** Templating System **")
|
formatter.write_text("** Templating System **")
|
||||||
formatter.write("\n")
|
formatter.write("\n")
|
||||||
formatter.write_text(
|
formatter.write_text(
|
||||||
"With the --directory option you may specify a template for the "
|
"With the --directory and --filename options you may specify a template for the "
|
||||||
+ "export directory. This directory will be appended to the export path specified "
|
+ "export directory or filename, respectively. "
|
||||||
|
+ "The directory will be appended to the export path specified "
|
||||||
+ "in the export DEST argument to export. For example, if template is "
|
+ "in the export DEST argument to export. For example, if template is "
|
||||||
+ "'{created.year}/{created.month}', and export desitnation DEST is "
|
+ "'{created.year}/{created.month}', and export desitnation DEST is "
|
||||||
+ "'/Users/maria/Pictures/export', "
|
+ "'/Users/maria/Pictures/export', "
|
||||||
+ "the actual export directory for a photo would be '/Users/maria/Pictures/export/2020/March' "
|
+ "the actual export directory for a photo would be '/Users/maria/Pictures/export/2020/March' "
|
||||||
+ "if the photo was created in March 2020. "
|
+ "if the photo was created in March 2020. "
|
||||||
|
+ "Some template substitutions may result in more than one value, for example '{album}' if "
|
||||||
|
+ "photo is in more than one album or '{keyword}' if photo has more than one keyword. "
|
||||||
|
+ "In this case, more than one copy of the photo will be exported, each in a separate directory "
|
||||||
|
+ "or with a different filename."
|
||||||
)
|
)
|
||||||
formatter.write("\n")
|
formatter.write("\n")
|
||||||
formatter.write_text(
|
formatter.write_text(
|
||||||
@@ -176,11 +178,7 @@ class ExportCommand(click.Command):
|
|||||||
formatter.write_text(
|
formatter.write_text(
|
||||||
"If you do not specify a default value and the template substitution "
|
"If you do not specify a default value and the template substitution "
|
||||||
+ "has no value, '_' (underscore) will be used as the default value. For example, in the "
|
+ "has no value, '_' (underscore) will be used as the default value. For example, in the "
|
||||||
+ "above example, this would result in '2020/_/photoname.jpg' if address was null"
|
+ "above example, this would result in '2020/_/photoname.jpg' if address was null."
|
||||||
)
|
|
||||||
formatter.write_text(
|
|
||||||
"I plan to eventually extend the templating system "
|
|
||||||
+ "to the exported filename so you can specify the filename using a template."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
formatter.write("\n")
|
formatter.write("\n")
|
||||||
@@ -1038,13 +1036,22 @@ def query(
|
|||||||
help="Optional template for specifying name of output directory in the form '{name,DEFAULT}'. "
|
help="Optional template for specifying name of output directory in the form '{name,DEFAULT}'. "
|
||||||
"See below for additional details on templating system.",
|
"See below for additional details on templating system.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--filename",
|
||||||
|
"filename_template",
|
||||||
|
metavar="FILENAME",
|
||||||
|
default=None,
|
||||||
|
help="Optional template for specifying name of output file in the form '{name,DEFAULT}'. "
|
||||||
|
"File extension will be added automatically--do not include an extension in the FILENAME template. "
|
||||||
|
"See below for additional details on templating system.",
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--edited-suffix",
|
"--edited-suffix",
|
||||||
metavar="SUFFIX",
|
metavar="SUFFIX",
|
||||||
default="_edited",
|
default="_edited",
|
||||||
help="Optional suffix for naming edited photos. Default name for edited photos is in form "
|
help="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 "
|
"'photoname_edited.ext'. For example, with '--edited-suffix _bearbeiten', the edited photo "
|
||||||
"would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'."
|
"would be named 'photoname_bearbeiten.ext'. The default suffix is '_edited'.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--no-extended-attributes",
|
"--no-extended-attributes",
|
||||||
@@ -1124,6 +1131,7 @@ def export(
|
|||||||
not_panorama,
|
not_panorama,
|
||||||
has_raw,
|
has_raw,
|
||||||
directory,
|
directory,
|
||||||
|
filename_template,
|
||||||
edited_suffix,
|
edited_suffix,
|
||||||
place,
|
place,
|
||||||
no_place,
|
no_place,
|
||||||
@@ -1288,6 +1296,7 @@ def export(
|
|||||||
photos.extend(burst_set)
|
photos.extend(burst_set)
|
||||||
|
|
||||||
num_photos = len(photos)
|
num_photos = len(photos)
|
||||||
|
# TODO: photos or photo appears several times, pull into a separate function
|
||||||
photo_str = "photos" if num_photos > 1 else "photo"
|
photo_str = "photos" if num_photos > 1 else "photo"
|
||||||
click.echo(f"Exporting {num_photos} {photo_str} to {dest}...")
|
click.echo(f"Exporting {num_photos} {photo_str} to {dest}...")
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
@@ -1310,6 +1319,7 @@ def export(
|
|||||||
download_missing=download_missing,
|
download_missing=download_missing,
|
||||||
exiftool=exiftool,
|
exiftool=exiftool,
|
||||||
directory=directory,
|
directory=directory,
|
||||||
|
filename_template=filename_template,
|
||||||
no_extended_attributes=no_extended_attributes,
|
no_extended_attributes=no_extended_attributes,
|
||||||
export_raw=export_raw,
|
export_raw=export_raw,
|
||||||
album_keyword=album_keyword,
|
album_keyword=album_keyword,
|
||||||
@@ -1317,7 +1327,7 @@ def export(
|
|||||||
keyword_template=keyword_template,
|
keyword_template=keyword_template,
|
||||||
export_db=export_db,
|
export_db=export_db,
|
||||||
fileutil=fileutil,
|
fileutil=fileutil,
|
||||||
dry_run = dry_run,
|
dry_run=dry_run,
|
||||||
edited_suffix=edited_suffix,
|
edited_suffix=edited_suffix,
|
||||||
)
|
)
|
||||||
results_exported.extend(results.exported)
|
results_exported.extend(results.exported)
|
||||||
@@ -1342,6 +1352,7 @@ def export(
|
|||||||
download_missing=download_missing,
|
download_missing=download_missing,
|
||||||
exiftool=exiftool,
|
exiftool=exiftool,
|
||||||
directory=directory,
|
directory=directory,
|
||||||
|
filename_template=filename_template,
|
||||||
no_extended_attributes=no_extended_attributes,
|
no_extended_attributes=no_extended_attributes,
|
||||||
export_raw=export_raw,
|
export_raw=export_raw,
|
||||||
album_keyword=album_keyword,
|
album_keyword=album_keyword,
|
||||||
@@ -1350,7 +1361,7 @@ def export(
|
|||||||
export_db=export_db,
|
export_db=export_db,
|
||||||
fileutil=fileutil,
|
fileutil=fileutil,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
edited_suffix=edited_suffix
|
edited_suffix=edited_suffix,
|
||||||
)
|
)
|
||||||
results_exported.extend(results.exported)
|
results_exported.extend(results.exported)
|
||||||
results_new.extend(results.new)
|
results_new.extend(results.new)
|
||||||
@@ -1762,6 +1773,7 @@ def export_photo(
|
|||||||
download_missing=None,
|
download_missing=None,
|
||||||
exiftool=None,
|
exiftool=None,
|
||||||
directory=None,
|
directory=None,
|
||||||
|
filename_template=None,
|
||||||
no_extended_attributes=None,
|
no_extended_attributes=None,
|
||||||
export_raw=None,
|
export_raw=None,
|
||||||
album_keyword=None,
|
album_keyword=None,
|
||||||
@@ -1770,9 +1782,11 @@ def export_photo(
|
|||||||
export_db=None,
|
export_db=None,
|
||||||
fileutil=FileUtil,
|
fileutil=FileUtil,
|
||||||
dry_run=None,
|
dry_run=None,
|
||||||
edited_suffix="_edited"
|
edited_suffix="_edited",
|
||||||
):
|
):
|
||||||
""" Helper function for export that does the actual export
|
""" Helper function for export that does the actual export
|
||||||
|
|
||||||
|
Args:
|
||||||
photo: PhotoInfo object
|
photo: PhotoInfo object
|
||||||
dest: destination path as string
|
dest: destination path as string
|
||||||
verbose_: boolean; print verbose output
|
verbose_: boolean; print verbose output
|
||||||
@@ -1786,6 +1800,7 @@ def export_photo(
|
|||||||
download_missing: attempt download of missing iCloud photos
|
download_missing: attempt download of missing iCloud photos
|
||||||
exiftool: use exiftool to write EXIF metadata directly to exported photo
|
exiftool: use exiftool to write EXIF metadata directly to exported photo
|
||||||
directory: template used to determine output directory
|
directory: template used to determine output directory
|
||||||
|
filename_template: template use to determine output file
|
||||||
no_extended_attributes: boolean; if True, exports photo without preserving extended attributes
|
no_extended_attributes: boolean; if True, exports photo without preserving extended attributes
|
||||||
export_raw: boolean; if True exports RAW image associate with the photo
|
export_raw: boolean; if True exports RAW image associate with the photo
|
||||||
album_keyword: boolean; if True, exports album names as keywords in metadata
|
album_keyword: boolean; if True, exports album names as keywords in metadata
|
||||||
@@ -1794,15 +1809,16 @@ def export_photo(
|
|||||||
export_db: export database instance compatible with ExportDB_ABC
|
export_db: export database instance compatible with ExportDB_ABC
|
||||||
fileutil: file util class compatible with FileUtilABC
|
fileutil: file util class compatible with FileUtilABC
|
||||||
dry_run: boolean; if True, doesn't actually export or update any files
|
dry_run: boolean; if True, doesn't actually export or update any files
|
||||||
returns list of path(s) of exported photo or None if photo was missing
|
|
||||||
|
Returns:
|
||||||
|
list of path(s) of exported photo or None if photo was missing
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError on invalid filename_template
|
||||||
"""
|
"""
|
||||||
global VERBOSE
|
global VERBOSE
|
||||||
VERBOSE = True if verbose_ else False
|
VERBOSE = True if verbose_ else False
|
||||||
|
|
||||||
# Can export to multiple paths
|
|
||||||
# Start with single path [dest] but direcotry and export_by_date will modify dest_paths
|
|
||||||
dest_paths = [dest]
|
|
||||||
|
|
||||||
if not download_missing:
|
if not download_missing:
|
||||||
if photo.ismissing:
|
if photo.ismissing:
|
||||||
space = " " if not verbose_ else ""
|
space = " " if not verbose_ else ""
|
||||||
@@ -1821,149 +1837,125 @@ def export_photo(
|
|||||||
)
|
)
|
||||||
return ExportResults([], [], [], [], [])
|
return ExportResults([], [], [], [], [])
|
||||||
|
|
||||||
filename = None
|
filenames = get_filenames_from_template(photo, filename_template, original_name)
|
||||||
if original_name:
|
for filename in filenames:
|
||||||
filename = photo.original_filename
|
verbose(f"Exporting {photo.filename} as {filename}")
|
||||||
else:
|
|
||||||
filename = photo.filename
|
|
||||||
|
|
||||||
verbose(f"Exporting {photo.filename} as {filename}")
|
dest_paths = get_dirnames_from_template(
|
||||||
|
photo, directory, export_by_date, dest, dry_run
|
||||||
if export_by_date:
|
|
||||||
date_created = DateTimeFormatter(photo.date)
|
|
||||||
dest_path = os.path.join(dest, date_created.year, date_created.mm, date_created.dd)
|
|
||||||
if not dry_run and not os.path.isdir(dest_path):
|
|
||||||
os.makedirs(dest_path)
|
|
||||||
dest_paths = [dest_path]
|
|
||||||
elif directory:
|
|
||||||
# got a directory template, render it and check results are valid
|
|
||||||
dirnames, unmatched = photo.render_template(directory)
|
|
||||||
if unmatched:
|
|
||||||
raise click.BadOptionUsage(
|
|
||||||
"directory",
|
|
||||||
f"Invalid substitution in template '{directory}': {unmatched}",
|
|
||||||
)
|
|
||||||
dest_paths = []
|
|
||||||
for dirname in dirnames:
|
|
||||||
dirname = sanitize_filepath(dirname, platform="auto")
|
|
||||||
dest_path = os.path.join(dest, dirname)
|
|
||||||
if not is_valid_filepath(dest_path, platform="auto"):
|
|
||||||
raise ValueError(f"Invalid file path: '{dest_path}'")
|
|
||||||
if not dry_run and not os.path.isdir(dest_path):
|
|
||||||
os.makedirs(dest_path)
|
|
||||||
dest_paths.append(dest_path)
|
|
||||||
|
|
||||||
sidecar = [s.lower() for s in sidecar]
|
|
||||||
sidecar_json = sidecar_xmp = False
|
|
||||||
if "json" in sidecar:
|
|
||||||
sidecar_json = True
|
|
||||||
if "xmp" in sidecar:
|
|
||||||
sidecar_xmp = True
|
|
||||||
|
|
||||||
# if download_missing and the photo is missing or path doesn't exist,
|
|
||||||
# try to download with Photos
|
|
||||||
use_photos_export = download_missing and (
|
|
||||||
photo.ismissing or not os.path.exists(photo.path)
|
|
||||||
)
|
|
||||||
|
|
||||||
# export the photo to each path in dest_paths
|
|
||||||
results_exported = []
|
|
||||||
results_new = []
|
|
||||||
results_updated = []
|
|
||||||
results_skipped = []
|
|
||||||
results_exif_updated = []
|
|
||||||
for dest_path in dest_paths:
|
|
||||||
export_results = photo.export2(
|
|
||||||
dest_path,
|
|
||||||
filename,
|
|
||||||
sidecar_json=sidecar_json,
|
|
||||||
sidecar_xmp=sidecar_xmp,
|
|
||||||
live_photo=export_live,
|
|
||||||
raw_photo=export_raw,
|
|
||||||
export_as_hardlink=export_as_hardlink,
|
|
||||||
overwrite=overwrite,
|
|
||||||
use_photos_export=use_photos_export,
|
|
||||||
exiftool=exiftool,
|
|
||||||
no_xattr=no_extended_attributes,
|
|
||||||
use_albums_as_keywords=album_keyword,
|
|
||||||
use_persons_as_keywords=person_keyword,
|
|
||||||
keyword_template=keyword_template,
|
|
||||||
update=update,
|
|
||||||
export_db=export_db,
|
|
||||||
fileutil=fileutil,
|
|
||||||
dry_run = dry_run,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
results_exported.extend(export_results.exported)
|
sidecar = [s.lower() for s in sidecar]
|
||||||
results_new.extend(export_results.new)
|
sidecar_json = sidecar_xmp = False
|
||||||
results_updated.extend(export_results.updated)
|
if "json" in sidecar:
|
||||||
results_skipped.extend(export_results.skipped)
|
sidecar_json = True
|
||||||
results_exif_updated.extend(export_results.exif_updated)
|
if "xmp" in sidecar:
|
||||||
|
sidecar_xmp = True
|
||||||
|
|
||||||
if verbose_:
|
# if download_missing and the photo is missing or path doesn't exist,
|
||||||
for exported in export_results.exported:
|
# try to download with Photos
|
||||||
verbose(f"Exported {exported}")
|
use_photos_export = download_missing and (
|
||||||
for new in export_results.new:
|
photo.ismissing or not os.path.exists(photo.path)
|
||||||
verbose(f"Exported new file {new}")
|
)
|
||||||
for updated in export_results.updated:
|
|
||||||
verbose(f"Exported updated file {updated}")
|
|
||||||
for skipped in export_results.skipped:
|
|
||||||
verbose(f"Skipped up to date file {skipped}")
|
|
||||||
|
|
||||||
# if export-edited, also export the edited version
|
# export the photo to each path in dest_paths
|
||||||
# verify the photo has adjustments and valid path to avoid raising an exception
|
results_exported = []
|
||||||
if export_edited and photo.hasadjustments:
|
results_new = []
|
||||||
# if download_missing and the photo is missing or path doesn't exist,
|
results_updated = []
|
||||||
# try to download with Photos
|
results_skipped = []
|
||||||
use_photos_export = download_missing and photo.path_edited is None
|
results_exif_updated = []
|
||||||
if not download_missing and photo.path_edited is None:
|
for dest_path in dest_paths:
|
||||||
verbose(f"Skipping missing edited photo for {filename}")
|
export_results = photo.export2(
|
||||||
else:
|
dest_path,
|
||||||
edited_name = pathlib.Path(filename)
|
filename,
|
||||||
# check for correct edited suffix
|
sidecar_json=sidecar_json,
|
||||||
if photo.path_edited is not None:
|
sidecar_xmp=sidecar_xmp,
|
||||||
edited_ext = pathlib.Path(photo.path_edited).suffix
|
live_photo=export_live,
|
||||||
|
raw_photo=export_raw,
|
||||||
|
export_as_hardlink=export_as_hardlink,
|
||||||
|
overwrite=overwrite,
|
||||||
|
use_photos_export=use_photos_export,
|
||||||
|
exiftool=exiftool,
|
||||||
|
no_xattr=no_extended_attributes,
|
||||||
|
use_albums_as_keywords=album_keyword,
|
||||||
|
use_persons_as_keywords=person_keyword,
|
||||||
|
keyword_template=keyword_template,
|
||||||
|
update=update,
|
||||||
|
export_db=export_db,
|
||||||
|
fileutil=fileutil,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
results_exported.extend(export_results.exported)
|
||||||
|
results_new.extend(export_results.new)
|
||||||
|
results_updated.extend(export_results.updated)
|
||||||
|
results_skipped.extend(export_results.skipped)
|
||||||
|
results_exif_updated.extend(export_results.exif_updated)
|
||||||
|
|
||||||
|
if verbose_:
|
||||||
|
for exported in export_results.exported:
|
||||||
|
verbose(f"Exported {exported}")
|
||||||
|
for new in export_results.new:
|
||||||
|
verbose(f"Exported new file {new}")
|
||||||
|
for updated in export_results.updated:
|
||||||
|
verbose(f"Exported updated file {updated}")
|
||||||
|
for skipped in export_results.skipped:
|
||||||
|
verbose(f"Skipped up to date file {skipped}")
|
||||||
|
|
||||||
|
# if export-edited, also export the edited version
|
||||||
|
# verify the photo has adjustments and valid path to avoid raising an exception
|
||||||
|
if export_edited and photo.hasadjustments:
|
||||||
|
# if download_missing and the photo is missing or path doesn't exist,
|
||||||
|
# try to download with Photos
|
||||||
|
use_photos_export = download_missing and photo.path_edited is None
|
||||||
|
if not download_missing and photo.path_edited is None:
|
||||||
|
verbose(f"Skipping missing edited photo for {filename}")
|
||||||
else:
|
else:
|
||||||
# use filename suffix which might be wrong,
|
edited_name = pathlib.Path(filename)
|
||||||
# will be corrected by use_photos_export
|
# check for correct edited suffix
|
||||||
edited_ext = pathlib.Path(photo.filename).suffix
|
if photo.path_edited is not None:
|
||||||
edited_name = f"{edited_name.stem}{edited_suffix}{edited_ext}"
|
edited_ext = pathlib.Path(photo.path_edited).suffix
|
||||||
verbose(f"Exporting edited version of {filename} as {edited_name}")
|
else:
|
||||||
export_results_edited = photo.export2(
|
# use filename suffix which might be wrong,
|
||||||
dest_path,
|
# will be corrected by use_photos_export
|
||||||
edited_name,
|
edited_ext = pathlib.Path(photo.filename).suffix
|
||||||
sidecar_json=sidecar_json,
|
edited_name = f"{edited_name.stem}{edited_suffix}{edited_ext}"
|
||||||
sidecar_xmp=sidecar_xmp,
|
verbose(f"Exporting edited version of {filename} as {edited_name}")
|
||||||
export_as_hardlink=export_as_hardlink,
|
export_results_edited = photo.export2(
|
||||||
overwrite=overwrite,
|
dest_path,
|
||||||
edited=True,
|
edited_name,
|
||||||
use_photos_export=use_photos_export,
|
sidecar_json=sidecar_json,
|
||||||
exiftool=exiftool,
|
sidecar_xmp=sidecar_xmp,
|
||||||
no_xattr=no_extended_attributes,
|
export_as_hardlink=export_as_hardlink,
|
||||||
use_albums_as_keywords=album_keyword,
|
overwrite=overwrite,
|
||||||
use_persons_as_keywords=person_keyword,
|
edited=True,
|
||||||
keyword_template=keyword_template,
|
use_photos_export=use_photos_export,
|
||||||
update=update,
|
exiftool=exiftool,
|
||||||
export_db=export_db,
|
no_xattr=no_extended_attributes,
|
||||||
fileutil=fileutil,
|
use_albums_as_keywords=album_keyword,
|
||||||
dry_run = dry_run,
|
use_persons_as_keywords=person_keyword,
|
||||||
)
|
keyword_template=keyword_template,
|
||||||
|
update=update,
|
||||||
|
export_db=export_db,
|
||||||
|
fileutil=fileutil,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
results_exported.extend(export_results_edited.exported)
|
results_exported.extend(export_results_edited.exported)
|
||||||
results_new.extend(export_results_edited.new)
|
results_new.extend(export_results_edited.new)
|
||||||
results_updated.extend(export_results_edited.updated)
|
results_updated.extend(export_results_edited.updated)
|
||||||
results_skipped.extend(export_results_edited.skipped)
|
results_skipped.extend(export_results_edited.skipped)
|
||||||
results_exif_updated.extend(export_results_edited.exif_updated)
|
results_exif_updated.extend(export_results_edited.exif_updated)
|
||||||
|
|
||||||
if verbose_:
|
if verbose_:
|
||||||
for exported in export_results_edited.exported:
|
for exported in export_results_edited.exported:
|
||||||
verbose(f"Exported {exported}")
|
verbose(f"Exported {exported}")
|
||||||
for new in export_results_edited.new:
|
for new in export_results_edited.new:
|
||||||
verbose(f"Exported new file {new}")
|
verbose(f"Exported new file {new}")
|
||||||
for updated in export_results_edited.updated:
|
for updated in export_results_edited.updated:
|
||||||
verbose(f"Exported updated file {updated}")
|
verbose(f"Exported updated file {updated}")
|
||||||
for skipped in export_results_edited.skipped:
|
for skipped in export_results_edited.skipped:
|
||||||
verbose(f"Skipped up to date file {skipped}")
|
verbose(f"Skipped up to date file {skipped}")
|
||||||
|
|
||||||
return ExportResults(
|
return ExportResults(
|
||||||
results_exported,
|
results_exported,
|
||||||
@@ -1974,5 +1966,83 @@ def export_photo(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_filenames_from_template(photo, filename_template, original_name):
|
||||||
|
""" get list of export filenames for a photo
|
||||||
|
|
||||||
|
Args:
|
||||||
|
photo: a PhotoInfo instance
|
||||||
|
filename_template: a PhotoTemplate template string, may be None
|
||||||
|
original_name: boolean; if True, use photo's original filename instead of current filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of filenames
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
click.BadOptionUsage if template is invalid
|
||||||
|
"""
|
||||||
|
if filename_template:
|
||||||
|
photo_ext = pathlib.Path(photo.original_filename).suffix
|
||||||
|
filenames, unmatched = photo.render_template(filename_template, path_sep="_")
|
||||||
|
if not filenames or unmatched:
|
||||||
|
raise click.BadOptionUsage(
|
||||||
|
"filename_template",
|
||||||
|
f"Invalid template '{filename_template}': results={filenames} unmatched={unmatched}",
|
||||||
|
)
|
||||||
|
filenames = [f"{file_}{photo_ext}" for file_ in filenames]
|
||||||
|
else:
|
||||||
|
if original_name:
|
||||||
|
filenames = [photo.original_filename]
|
||||||
|
else:
|
||||||
|
filenames = [photo.filename]
|
||||||
|
return filenames
|
||||||
|
|
||||||
|
|
||||||
|
def get_dirnames_from_template(photo, directory, export_by_date, dest, dry_run):
|
||||||
|
""" get list of directories to export a photo into, creates directories if they don't exist
|
||||||
|
|
||||||
|
Args:
|
||||||
|
photo: a PhotoInstance object
|
||||||
|
directory: a PhotoTemplate template string, may be None
|
||||||
|
export_by_date: boolean; if True, creates output directories in form YYYY-MM-DD
|
||||||
|
dest: top-level destination directory
|
||||||
|
dry_run: boolean; if True, runs in dry-run mode and does not create output directories
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of export directories
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
click.BadOptionUsage if template is invalid
|
||||||
|
"""
|
||||||
|
|
||||||
|
if export_by_date:
|
||||||
|
date_created = DateTimeFormatter(photo.date)
|
||||||
|
dest_path = os.path.join(
|
||||||
|
dest, date_created.year, date_created.mm, date_created.dd
|
||||||
|
)
|
||||||
|
if not dry_run and not os.path.isdir(dest_path):
|
||||||
|
os.makedirs(dest_path)
|
||||||
|
dest_paths = [dest_path]
|
||||||
|
elif directory:
|
||||||
|
# got a directory template, render it and check results are valid
|
||||||
|
dirnames, unmatched = photo.render_template(directory)
|
||||||
|
if not dirnames or unmatched:
|
||||||
|
raise click.BadOptionUsage(
|
||||||
|
"directory",
|
||||||
|
f"Invalid template '{directory}': results={dirnames} unmatched={unmatched}",
|
||||||
|
)
|
||||||
|
dest_paths = []
|
||||||
|
for dirname in dirnames:
|
||||||
|
dirname = sanitize_filepath(dirname, platform="auto")
|
||||||
|
dest_path = os.path.join(dest, dirname)
|
||||||
|
if not is_valid_filepath(dest_path, platform="auto"):
|
||||||
|
raise ValueError(f"Invalid file path: '{dest_path}'")
|
||||||
|
if not dry_run and not os.path.isdir(dest_path):
|
||||||
|
os.makedirs(dest_path)
|
||||||
|
dest_paths.append(dest_path)
|
||||||
|
else:
|
||||||
|
dest_paths = [dest]
|
||||||
|
return dest_paths
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli() # pylint: disable=no-value-for-parameter
|
cli() # pylint: disable=no-value-for-parameter
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
""" version info """
|
""" version info """
|
||||||
|
|
||||||
__version__ = "0.29.8"
|
__version__ = "0.29.9"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from ..placeinfo import PlaceInfo4, PlaceInfo5
|
|||||||
from ..phototemplate import PhotoTemplate
|
from ..phototemplate import PhotoTemplate
|
||||||
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
|
from ..utils import _debug, _get_resource_loc, findfiles, get_preferred_uti_extension
|
||||||
|
|
||||||
|
|
||||||
class PhotoInfo:
|
class PhotoInfo:
|
||||||
"""
|
"""
|
||||||
Info about a specific photo, contains all the details about the photo
|
Info about a specific photo, contains all the details about the photo
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ RAW_PHOTOS_DB = "tests/Test-RAW-10.15.1.photoslibrary"
|
|||||||
PLACES_PHOTOS_DB = "tests/Test-Places-Catalina-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"
|
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_15_4 = "tests/Test-10.15.4.photoslibrary"
|
||||||
|
PHOTOS_DB_15_5 = "tests/Test-10.15.5.photoslibrary"
|
||||||
PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary"
|
PHOTOS_DB_14_6 = "tests/Test-10.14.6.photoslibrary"
|
||||||
|
|
||||||
CLI_OUTPUT_NO_SUBCOMMAND = [
|
CLI_OUTPUT_NO_SUBCOMMAND = [
|
||||||
@@ -132,6 +134,38 @@ CLI_EXPORTED_DIRECTORY_TEMPLATE_FILENAMES3 = [
|
|||||||
"2018/{foo}/Pumkins1.jpg",
|
"2018/{foo}/Pumkins1.jpg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1 = [
|
||||||
|
"2019-wedding.jpg",
|
||||||
|
"2019-wedding_edited.jpeg",
|
||||||
|
"2019-Tulips.jpg",
|
||||||
|
"2018-St James Park.jpg",
|
||||||
|
"2018-St James Park_edited.jpeg",
|
||||||
|
"2018-Pumpkins3.jpg",
|
||||||
|
"2018-Pumkins2.jpg",
|
||||||
|
"2018-Pumkins1.jpg",
|
||||||
|
]
|
||||||
|
|
||||||
|
CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2 = [
|
||||||
|
"Folder1_SubFolder2_AlbumInFolder-IMG_4547.jpg",
|
||||||
|
"Folder1_SubFolder2_AlbumInFolder-wedding.jpg",
|
||||||
|
"Folder1_SubFolder2_AlbumInFolder-wedding_edited.jpeg",
|
||||||
|
"Folder2_Raw-DSC03584.dng",
|
||||||
|
"Folder2_Raw-IMG_1994.cr2",
|
||||||
|
"Folder2_Raw-IMG_1994.JPG",
|
||||||
|
"Folder2_Raw-IMG_1997.cr2",
|
||||||
|
"Folder2_Raw-IMG_1997.JPG",
|
||||||
|
"None-St James Park.jpg",
|
||||||
|
"None-St James Park_edited.jpeg",
|
||||||
|
"None-Tulips.jpg",
|
||||||
|
"None-Tulips_edited.jpeg",
|
||||||
|
"Pumpkin Farm-Pumkins1.jpg",
|
||||||
|
"Pumpkin Farm-Pumkins2.jpg",
|
||||||
|
"Pumpkin Farm-Pumpkins3.jpg",
|
||||||
|
"Test Album-Pumkins1.jpg",
|
||||||
|
"Test Album-Pumkins2.jpg",
|
||||||
|
]
|
||||||
|
|
||||||
CLI_EXPORT_UUID = "D79B8D77-BFFC-460B-9312-034F2877D35B"
|
CLI_EXPORT_UUID = "D79B8D77-BFFC-460B-9312-034F2877D35B"
|
||||||
|
|
||||||
CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg"
|
CLI_EXPORT_UUID_FILENAME = "Pumkins2.jpg"
|
||||||
@@ -706,7 +740,7 @@ def test_export_directory_template_2():
|
|||||||
|
|
||||||
|
|
||||||
def test_export_directory_template_3():
|
def test_export_directory_template_3():
|
||||||
# test export using directory template with unmatched substituion value
|
# test export using directory template with unmatched substitution value
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
@@ -728,7 +762,7 @@ def test_export_directory_template_3():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
assert result.exit_code == 2
|
assert result.exit_code == 2
|
||||||
assert "Error: Invalid substitution in template" in result.output
|
assert "Error: Invalid template" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_export_directory_template_album_1():
|
def test_export_directory_template_album_1():
|
||||||
@@ -825,6 +859,93 @@ def test_export_directory_template_locale():
|
|||||||
assert os.path.isfile(os.path.join(workdir, filepath))
|
assert os.path.isfile(os.path.join(workdir, filepath))
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_filename_template_1():
|
||||||
|
""" export photos using filename template """
|
||||||
|
import glob
|
||||||
|
import locale
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import osxphotos
|
||||||
|
from osxphotos.__main__ import export
|
||||||
|
|
||||||
|
locale.setlocale(locale.LC_ALL, "en_US")
|
||||||
|
|
||||||
|
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),
|
||||||
|
".",
|
||||||
|
"-V",
|
||||||
|
"--filename",
|
||||||
|
"{created.year}-{original_name}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
workdir = os.getcwd()
|
||||||
|
files = glob.glob("*.*")
|
||||||
|
assert sorted(files) == sorted(CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_filename_template_2():
|
||||||
|
""" export photos using filename template with folder_album and path_sep """
|
||||||
|
import glob
|
||||||
|
import locale
|
||||||
|
import os
|
||||||
|
import os.path
|
||||||
|
import osxphotos
|
||||||
|
from osxphotos.__main__ import export
|
||||||
|
|
||||||
|
locale.setlocale(locale.LC_ALL, "en_US")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
cwd = os.getcwd()
|
||||||
|
# pylint: disable=not-context-manager
|
||||||
|
with runner.isolated_filesystem():
|
||||||
|
result = runner.invoke(
|
||||||
|
export,
|
||||||
|
[
|
||||||
|
os.path.join(cwd, PHOTOS_DB_15_5),
|
||||||
|
".",
|
||||||
|
"-V",
|
||||||
|
"--filename",
|
||||||
|
"{folder_album,None}-{original_name}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
files = glob.glob("*.*")
|
||||||
|
assert sorted(files) == sorted(CLI_EXPORTED_FILENAME_TEMPLATE_FILENAMES2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_filename_template_3():
|
||||||
|
""" test --filename with invalid 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),
|
||||||
|
".",
|
||||||
|
"-V",
|
||||||
|
"--directory",
|
||||||
|
"{foo}-{original_filename}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "Error: Invalid template" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_places():
|
def test_places():
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|||||||
Reference in New Issue
Block a user